37 changed files with 1829 additions and 1169 deletions
+1
View File
@@ -130,6 +130,7 @@ APK 文件名会自动包含应用名、版本号、版本代码、构建类型
| 构建类型 | 后端环境 | 调试 | 代码压缩 | | 构建类型 | 后端环境 | 调试 | 代码压缩 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `debug` | 测试环境 `api-test.zhifly.cn` | 开启 | 关闭 | | `debug` | 测试环境 `api-test.zhifly.cn` | 开启 | 关闭 |
| `releaseDebug` | 正式环境 `api.zhifly.cn` | 开启 | 关闭 |
| `release` | 正式环境 `api.zhifly.cn` | 关闭 | 关闭 | | `release` | 正式环境 `api.zhifly.cn` | 关闭 | 关闭 |
请勿使用 Release 包连接测试设备随意操作,Release 会访问正式接口并连接正式 WebSocket。 请勿使用 Release 包连接测试设备随意操作,Release 会访问正式接口并连接正式 WebSocket。
+9 -2
View File
@@ -89,8 +89,8 @@ android {
applicationId = "com.yzx.kiosk" applicationId = "com.yzx.kiosk"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 21 versionCode = 23
versionName = "1.1.1" versionName = "1.1.3"
//multiDexEnabled = true //multiDexEnabled = true
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -161,6 +161,13 @@ android {
"\"https://vipsky.oss-cn-shanghai.aliyuncs.com/cloud_driver\"" "\"https://vipsky.oss-cn-shanghai.aliyuncs.com/cloud_driver\""
) )
} }
create("releaseDebug") {
initWith(getByName("release"))
isDebuggable = true
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks += listOf("release", "debug")
}
} }
compileOptions { compileOptions {
@@ -0,0 +1,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()
}
}
+15 -1
View File
@@ -12,6 +12,7 @@ import com.luck.picture.lib.basic.PictureSelectorSupporterActivity
import com.luck.picture.lib.basic.PictureSelectorTransparentActivity import com.luck.picture.lib.basic.PictureSelectorTransparentActivity
import com.yzx.kiosk.datastore.AppState import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.datastore.AppStoreDataSource import com.yzx.kiosk.datastore.AppStoreDataSource
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.LogUtils
import com.yzx.kiosk.utils.MMKVUtils import com.yzx.kiosk.utils.MMKVUtils
import com.yzx.kiosk.utils.NavigationBarUtil import com.yzx.kiosk.utils.NavigationBarUtil
@@ -42,6 +43,9 @@ class App : Application() {
@Inject @Inject
lateinit var appStoreDataSource: AppStoreDataSource lateinit var appStoreDataSource: AppStoreDataSource
@Inject
lateinit var faceCaptureFileStore: FaceCaptureFileStore
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
@@ -59,6 +63,16 @@ class App : Application() {
LogUtils.init(BuildConfig.DEBUG) LogUtils.init(BuildConfig.DEBUG)
MMKVUtils.init(this) MMKVUtils.init(this)
runCatching { faceCaptureFileStore.cleanupOrphans() }
.onSuccess { deletedFaceCaptures ->
if (deletedFaceCaptures > 0) {
LogUtils.i("App", "Cleaned $deletedFaceCaptures orphaned face capture file(s)")
}
}
.onFailure { error ->
LogUtils.e("App", "Failed to clean orphaned face captures: ${error.message}")
}
appState.initialize() appState.initialize()
initCoil() initCoil()
@@ -120,4 +134,4 @@ class App : Application() {
}) })
} }
} }
@@ -44,7 +44,6 @@ class LocalAudioPlayService @Inject constructor(
AppRoutes.FACE_RECOGNITION_RESULT to R.raw.face_recognition_success_result_page, AppRoutes.FACE_RECOGNITION_RESULT to R.raw.face_recognition_success_result_page,
AppRoutes.UPLOAD_PHOTO to R.raw.scan_qrcode_upload_page, AppRoutes.UPLOAD_PHOTO to R.raw.scan_qrcode_upload_page,
AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page, AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page,
AppRoutes.PAY_SUCCESS to R.raw.pay_success_select_photo_print_page,
AppRoutes.PRINTING to R.raw.printing_page, AppRoutes.PRINTING to R.raw.printing_page,
AppRoutes.PRINT_SUCCESS to R.raw.print_complete_page, AppRoutes.PRINT_SUCCESS to R.raw.print_complete_page,
// 特殊状态页面 // 特殊状态页面
@@ -17,7 +17,6 @@ import com.yzx.kiosk.ui.printer.view.PrinterManageScreen
import com.yzx.kiosk.ui.setting.view.SettingScreen import com.yzx.kiosk.ui.setting.view.SettingScreen
import com.yzx.kiosk.ui.face.view.FaceRecognitionScreen import com.yzx.kiosk.ui.face.view.FaceRecognitionScreen
import com.yzx.kiosk.ui.face.view.FaceRecognitionResultScreen import com.yzx.kiosk.ui.face.view.FaceRecognitionResultScreen
import com.yzx.kiosk.ui.upload.view.PaySuccessScreen
import com.yzx.kiosk.ui.upload.view.PhotoSelectScreen import com.yzx.kiosk.ui.upload.view.PhotoSelectScreen
import com.yzx.kiosk.ui.upload.view.PrintingScreen import com.yzx.kiosk.ui.upload.view.PrintingScreen
import com.yzx.kiosk.ui.upload.view.UploadPhotoScreen import com.yzx.kiosk.ui.upload.view.UploadPhotoScreen
@@ -157,48 +156,6 @@ fun AppNavHost(
FaceRecognitionResultScreen(results = results) 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
)
}
// 打印中页面 // 打印中页面
composable(route = "${AppRoutes.PRINTING}?urls={urls}&orderNumber={orderNumber}&captureType={captureType}") { backStackEntry -> composable(route = "${AppRoutes.PRINTING}?urls={urls}&orderNumber={orderNumber}&captureType={captureType}") { backStackEntry ->
val urlsParam = backStackEntry.arguments?.getString("urls") ?: "" val urlsParam = backStackEntry.arguments?.getString("urls") ?: ""
@@ -7,11 +7,24 @@ object AppRoutes {
const val DEVICE_CONFIG = "device_config" const val DEVICE_CONFIG = "device_config"
const val UPLOAD_PHOTO = "upload_photo" const val UPLOAD_PHOTO = "upload_photo"
const val PHOTO_SELECT = "photo_select" const val PHOTO_SELECT = "photo_select"
const val PAY_SUCCESS = "pay_success"
const val PRINTING = "printing" const val PRINTING = "printing"
const val PRINT_SUCCESS = "print_success" const val PRINT_SUCCESS = "print_success"
const val FACE_RECOGNITION = "face_recognition" const val FACE_RECOGNITION = "face_recognition"
const val FACE_RECOGNITION_RESULT = "face_recognition_result" const val FACE_RECOGNITION_RESULT = "face_recognition_result"
const val AGREEMENT = "agreement" const val AGREEMENT = "agreement"
fun buildPrintingRoute(
photoData: String,
orderNumber: String?,
captureType: Int?
): String {
val urlsParam = java.net.URLEncoder.encode(photoData, "UTF-8")
val orderNumberParam = orderNumber
?.let { java.net.URLEncoder.encode(it, "UTF-8") }
.orEmpty()
val captureTypeParam = captureType?.toString().orEmpty()
return "$PRINTING?urls=$urlsParam&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
}
} }
@@ -28,8 +28,10 @@ object NetworkModule {
private const val CLIENT_DEFAULT = "defaultOkHttpClient" private const val CLIENT_DEFAULT = "defaultOkHttpClient"
const val CLIENT_UPLOAD = "uploadOkHttpClient" const val CLIENT_UPLOAD = "uploadOkHttpClient"
const val CLIENT_DOWNLOAD = "downloadOkHttpClient" const val CLIENT_DOWNLOAD = "downloadOkHttpClient"
const val CLIENT_FACE = "faceOkHttpClient"
const val RETROFIT_DEFAULT = "defaultRetrofit" const val RETROFIT_DEFAULT = "defaultRetrofit"
const val RETROFIT_UPLOAD = "uploadRetrofit" const val RETROFIT_UPLOAD = "uploadRetrofit"
const val RETROFIT_FACE = "faceRetrofit"
const val TRANSFRTVIEWMODEL = "transferViewModel" const val TRANSFRTVIEWMODEL = "transferViewModel"
@Provides @Provides
@@ -104,6 +106,17 @@ object NetworkModule {
} }
.build() .build()
@Provides
@Singleton
@Named(CLIENT_FACE)
fun provideFaceOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
}
}
.build()
private fun buildRetrofit( private fun buildRetrofit(
baseUrl: String, baseUrl: String,
okHttpClient: OkHttpClient, okHttpClient: OkHttpClient,
@@ -131,4 +144,13 @@ object NetworkModule {
gson: Gson, gson: Gson,
@Named(BASE_URL) baseUrl: String @Named(BASE_URL) baseUrl: String
): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson) ): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson)
@Provides
@Singleton
@Named(RETROFIT_FACE)
fun provideFaceRetrofit(
@Named(CLIENT_FACE) okHttpClient: OkHttpClient,
gson: Gson,
@Named(BASE_URL) baseUrl: String,
): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson)
} }
@@ -4,6 +4,7 @@ import android.content.Context
import com.yzx.kiosk.audio.AudioPlayService import com.yzx.kiosk.audio.AudioPlayService
import com.yzx.kiosk.network.service.NetworkService import com.yzx.kiosk.network.service.NetworkService
import com.yzx.kiosk.network.service.UploadService import com.yzx.kiosk.network.service.UploadService
import com.yzx.kiosk.network.service.FaceSearchService
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -11,6 +12,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_DEFAULT import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_DEFAULT
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_UPLOAD import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_UPLOAD
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_FACE
import retrofit2.Retrofit import retrofit2.Retrofit
import javax.inject.Named import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@@ -30,6 +32,12 @@ object ServiceModule {
fun provideUploadService( fun provideUploadService(
@Named(RETROFIT_UPLOAD) retrofit: Retrofit @Named(RETROFIT_UPLOAD) retrofit: Retrofit
): UploadService = retrofit.create(UploadService::class.java) ): UploadService = retrofit.create(UploadService::class.java)
@Provides
@Singleton
fun provideFaceSearchService(
@Named(RETROFIT_FACE) retrofit: Retrofit,
): FaceSearchService = retrofit.create(FaceSearchService::class.java)
@Provides @Provides
@Singleton @Singleton
@@ -42,4 +50,4 @@ object ServiceModule {
fun provideLocalAudioPlayService( fun provideLocalAudioPlayService(
@ApplicationContext context: Context @ApplicationContext context: Context
): com.yzx.kiosk.audio.LocalAudioPlayService = com.yzx.kiosk.audio.LocalAudioPlayService(context) ): com.yzx.kiosk.audio.LocalAudioPlayService = com.yzx.kiosk.audio.LocalAudioPlayService(context)
} }
@@ -0,0 +1,8 @@
package com.yzx.kiosk.network.model.request
import com.google.gson.annotations.SerializedName
data class PaySuccessMessageRequest(
@SerializedName("order_number")
val orderNumber: String
)
@@ -4,6 +4,9 @@ import com.google.gson.annotations.SerializedName
data class GetPayUrlResponse( data class GetPayUrlResponse(
@SerializedName("url") @SerializedName("url")
val url: String? val url: String?,
@SerializedName("order_number")
val orderNumber: String?
) )
@@ -0,0 +1,31 @@
package com.yzx.kiosk.network.model.response
import com.google.gson.annotations.SerializedName
data class PaySuccessMessageResponse(
@SerializedName("order_status")
val orderStatus: Int?,
@SerializedName("order_status_name")
val orderStatusName: String?,
@SerializedName("sn")
val sn: String?,
@SerializedName("type")
val type: Int?,
@SerializedName("data")
val data: PaySuccessMessageData?
)
data class PaySuccessMessageData(
@SerializedName("order_number")
val orderNumber: String?,
@SerializedName("capture_type")
val captureType: Int?,
@SerializedName("image_id")
val imageIds: List<Int>?
)
@@ -1,6 +1,8 @@
package com.yzx.kiosk.network.repository package com.yzx.kiosk.network.repository
import com.google.gson.Gson
import com.yzx.kiosk.network.model.request.GetPayUrlRequest import com.yzx.kiosk.network.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
import com.yzx.kiosk.network.model.request.PrintCompleteRequest import com.yzx.kiosk.network.model.request.PrintCompleteRequest
import com.yzx.kiosk.network.model.request.PrintNotifyRequest import com.yzx.kiosk.network.model.request.PrintNotifyRequest
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
@@ -13,6 +15,7 @@ import com.yzx.kiosk.network.model.response.GetPayUrlResponse
import com.yzx.kiosk.network.model.response.GetPrintInfoResponse import com.yzx.kiosk.network.model.response.GetPrintInfoResponse
import com.yzx.kiosk.network.model.response.NetworkResponse import com.yzx.kiosk.network.model.response.NetworkResponse
import com.yzx.kiosk.network.model.response.OscarLivesData import com.yzx.kiosk.network.model.response.OscarLivesData
import com.yzx.kiosk.network.model.response.PaySuccessMessageResponse
import com.yzx.kiosk.network.model.response.SocketTokenResponse import com.yzx.kiosk.network.model.response.SocketTokenResponse
import com.yzx.kiosk.network.model.response.VerifyResultResponse import com.yzx.kiosk.network.model.response.VerifyResultResponse
import com.yzx.kiosk.network.model.response.VersionResponse import com.yzx.kiosk.network.model.response.VersionResponse
@@ -27,6 +30,7 @@ import javax.inject.Inject
class NetWorkRepository @Inject constructor( class NetWorkRepository @Inject constructor(
private val netService: NetworkService, private val netService: NetworkService,
private val uploadService: UploadService, private val uploadService: UploadService,
private val gson: Gson,
) { ) {
// 基础方法示例,可根据需要添加 // 基础方法示例,可根据需要添加
// fun example(): Flow<NetworkResponse<Any>> = flow { // fun example(): Flow<NetworkResponse<Any>> = flow {
@@ -81,6 +85,23 @@ class NetWorkRepository @Inject constructor(
fun getPayUrl(request: GetPayUrlRequest): Flow<NetworkResponse<GetPayUrlResponse>> = flow { fun getPayUrl(request: GetPayUrlRequest): Flow<NetworkResponse<GetPayUrlResponse>> = flow {
emit(netService.getPayUrl(request)) emit(netService.getPayUrl(request))
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
fun getPaySuccessMessage(
request: PaySuccessMessageRequest
): Flow<NetworkResponse<PaySuccessMessageResponse>> = flow {
val response = netService.getPaySuccessMessage(request)
val parsedData = response.data
?.takeIf { it.isJsonObject }
?.let { gson.fromJson(it, PaySuccessMessageResponse::class.java) }
emit(
NetworkResponse(
data = parsedData,
code = response.code,
message = response.message
)
)
}.flowOn(Dispatchers.IO)
fun getSocketToken(): Flow<NetworkResponse<SocketTokenResponse>> = flow { fun getSocketToken(): Flow<NetworkResponse<SocketTokenResponse>> = flow {
emit(netService.getSocketToken()) emit(netService.getSocketToken())
@@ -97,4 +118,4 @@ class NetWorkRepository @Inject constructor(
fun saveAlbumUrl(request: SaveAlbumUrlRequest): Flow<NetworkResponse<GetCodeUrlResponse>> = flow { fun saveAlbumUrl(request: SaveAlbumUrlRequest): Flow<NetworkResponse<GetCodeUrlResponse>> = flow {
emit(netService.saveAlbumUrl(request)) emit(netService.saveAlbumUrl(request))
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
} }
@@ -8,13 +8,13 @@ import retrofit2.http.Header
import retrofit2.http.Multipart import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Url
interface FaceSearchService { interface FaceSearchService {
@Multipart @Multipart
@POST("/{sn}/api/search") @POST
suspend fun searchFace( suspend fun searchFace(
@Path("sn") sn: String, @Url url: String,
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Part image: MultipartBody.Part, @Part image: MultipartBody.Part,
@Part("threshold") threshold: RequestBody?, @Part("threshold") threshold: RequestBody?,
@@ -0,0 +1,21 @@
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 existing /{sn}/api/search endpoint semantics even if configuration
// accidentally contains a path or query string.
.encodedPath("/")
.query(null)
.fragment(null)
.addPathSegment(deviceSn)
.addPathSegment("api")
.addPathSegment("search")
.build()
.toString()
}
@@ -1,6 +1,7 @@
package com.yzx.kiosk.network.service package com.yzx.kiosk.network.service
import com.yzx.kiosk.network.model.request.GetPayUrlRequest import com.yzx.kiosk.network.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
import com.yzx.kiosk.network.model.request.PrintCompleteRequest import com.yzx.kiosk.network.model.request.PrintCompleteRequest
import com.yzx.kiosk.network.model.request.PrintNotifyRequest import com.yzx.kiosk.network.model.request.PrintNotifyRequest
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
@@ -16,6 +17,7 @@ import com.yzx.kiosk.network.model.response.OscarLivesData
import com.yzx.kiosk.network.model.response.SocketTokenResponse import com.yzx.kiosk.network.model.response.SocketTokenResponse
import com.yzx.kiosk.network.model.response.VerifyResultResponse import com.yzx.kiosk.network.model.response.VerifyResultResponse
import com.yzx.kiosk.network.model.response.VersionResponse import com.yzx.kiosk.network.model.response.VersionResponse
import com.google.gson.JsonElement
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST import retrofit2.http.POST
@@ -68,6 +70,11 @@ interface NetworkService {
@POST("/api/oscar/order/get-pay-url") @POST("/api/oscar/order/get-pay-url")
suspend fun getPayUrl(@Body request: GetPayUrlRequest): NetworkResponse<GetPayUrlResponse> suspend fun getPayUrl(@Body request: GetPayUrlRequest): NetworkResponse<GetPayUrlResponse>
@POST("/api/oscar/order/pay-success-message")
suspend fun getPaySuccessMessage(
@Body request: PaySuccessMessageRequest
): NetworkResponse<JsonElement>
@GET("/api/oscar/socket-token") @GET("/api/oscar/socket-token")
suspend fun getSocketToken(): NetworkResponse<SocketTokenResponse> suspend fun getSocketToken(): NetworkResponse<SocketTokenResponse>
@@ -0,0 +1,303 @@
package com.yzx.kiosk.priter
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.security.MessageDigest
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
/**
* 打印图片完整性校验。
*
* 网络图片必须先完整下载为字节数组,再经过容器、尺寸和软件 Bitmap 解码校验;
* 最终 BMP 必须通过文件头、尺寸、位深和精确文件长度校验后才允许送入打印 SDK。
*/
object PrintImageIntegrityValidator {
const val MAX_DOWNLOAD_BYTES: Long = 50L * 1024L * 1024L
private const val MIN_IMAGE_BYTES = 128
private const val MAX_IMAGE_DIMENSION = 30_000
private const val MAX_DECODED_PIXELS = 24_000_000L
private const val TARGET_LONG_EDGE = 1_840
private const val TARGET_SHORT_EDGE = 1_240
data class DecodedImage(
val bitmap: Bitmap,
val format: String,
val mimeType: String?,
val sourceWidth: Int,
val sourceHeight: Int,
val sampleSize: Int,
val sha256: String
)
data class BmpValidation(
val width: Int,
val height: Int,
val bitsPerPixel: Int,
val fileSize: Long,
val sha256: String
)
fun decodeDownloadedImage(bytes: ByteArray): DecodedImage {
if (bytes.size < MIN_IMAGE_BYTES) {
throw IOException("图片数据过小: ${bytes.size} bytes")
}
if (bytes.size.toLong() > MAX_DOWNLOAD_BYTES) {
throw IOException("图片数据超过上限: ${bytes.size} bytes")
}
val format = detectAndValidateContainer(bytes)
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
val sourceWidth = bounds.outWidth
val sourceHeight = bounds.outHeight
if (sourceWidth <= 0 || sourceHeight <= 0) {
throw IOException("无法解析图片尺寸,format=$format, mime=${bounds.outMimeType}")
}
if (sourceWidth > MAX_IMAGE_DIMENSION || sourceHeight > MAX_IMAGE_DIMENSION) {
throw IOException("图片尺寸异常: ${sourceWidth}x${sourceHeight}")
}
val sampleSize = calculateInSampleSize(sourceWidth, sourceHeight)
val decodeOptions = BitmapFactory.Options().apply {
inJustDecodeBounds = false
inPreferredConfig = Bitmap.Config.ARGB_8888
inSampleSize = sampleSize
inScaled = false
}
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOptions)
?: throw IOException("图片完整解码失败: ${sourceWidth}x${sourceHeight}, format=$format")
if (decoded.width <= 0 || decoded.height <= 0 || decoded.isRecycled) {
decoded.recycle()
throw IOException("解码后的 Bitmap 无效")
}
if (decoded.config == Bitmap.Config.HARDWARE) {
decoded.recycle()
throw IOException("打印图片意外解码为硬件 Bitmap")
}
// 主动读取像素,确保 Bitmap 的像素缓冲区可访问。
try {
decoded.getPixel(decoded.width / 2, decoded.height / 2)
} catch (e: Exception) {
decoded.recycle()
throw IOException("Bitmap 像素缓冲区不可读", e)
}
return DecodedImage(
bitmap = decoded,
format = format,
mimeType = bounds.outMimeType,
sourceWidth = sourceWidth,
sourceHeight = sourceHeight,
sampleSize = sampleSize,
sha256 = sha256(bytes)
)
}
fun validateBmp(file: File, expectedWidth: Int, expectedHeight: Int): BmpValidation {
if (!file.isFile || file.length() < 54L) {
throw IOException("BMP 文件不存在或过小: ${file.absolutePath}")
}
val header = ByteArray(54)
FileInputStream(file).use { input ->
var offset = 0
while (offset < header.size) {
val read = input.read(header, offset, header.size - offset)
if (read < 0) break
offset += read
}
if (offset != header.size) {
throw IOException("BMP 文件头不完整: $offset/${header.size}")
}
}
if (header[0] != 'B'.code.toByte() || header[1] != 'M'.code.toByte()) {
throw IOException("BMP 文件签名错误")
}
val declaredFileSize = uint32Le(header, 2)
val pixelOffset = uint32Le(header, 10)
val dibHeaderSize = uint32Le(header, 14)
val width = int32Le(header, 18)
val rawHeight = int32Le(header, 22)
val height = abs(rawHeight)
val planes = uint16Le(header, 26)
val bitsPerPixel = uint16Le(header, 28)
val compression = uint32Le(header, 30)
if (dibHeaderSize < 40L || pixelOffset < 54L) {
throw IOException("BMP 文件头结构异常")
}
if (width != expectedWidth || height != expectedHeight) {
throw IOException("BMP 尺寸不匹配: ${width}x${height}, expected=${expectedWidth}x${expectedHeight}")
}
if (planes != 1 || bitsPerPixel != 24 || compression != 0L) {
throw IOException("BMP 格式不受支持: planes=$planes, bpp=$bitsPerPixel, compression=$compression")
}
val rowStride = ((width.toLong() * 3L + 3L) / 4L) * 4L
val expectedFileSize = pixelOffset + rowStride * height.toLong()
val actualFileSize = file.length()
if (declaredFileSize != actualFileSize || expectedFileSize != actualFileSize) {
throw IOException(
"BMP 文件长度异常: declared=$declaredFileSize, expected=$expectedFileSize, actual=$actualFileSize"
)
}
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, bounds)
if (bounds.outWidth != expectedWidth || abs(bounds.outHeight) != expectedHeight) {
throw IOException("BMP 无法重新解析: ${bounds.outWidth}x${bounds.outHeight}")
}
return BmpValidation(
width = width,
height = height,
bitsPerPixel = bitsPerPixel,
fileSize = actualFileSize,
sha256 = sha256(file)
)
}
private fun calculateInSampleSize(width: Int, height: Int): Int {
val longEdge = max(width, height)
val shortEdge = min(width, height)
var sampleSize = 1
while (
longEdge / (sampleSize * 2) >= TARGET_LONG_EDGE &&
shortEdge / (sampleSize * 2) >= TARGET_SHORT_EDGE
) {
sampleSize *= 2
}
while (
width.toLong() / sampleSize * (height.toLong() / sampleSize) > MAX_DECODED_PIXELS
) {
sampleSize *= 2
}
return sampleSize
}
private fun detectAndValidateContainer(bytes: ByteArray): String {
return when {
isJpeg(bytes) -> {
val searchStart = max(2, bytes.size - 64)
var hasEndMarker = false
for (index in bytes.size - 2 downTo searchStart) {
if (byteAt(bytes, index) == 0xFF && byteAt(bytes, index + 1) == 0xD9) {
hasEndMarker = true
break
}
}
if (!hasEndMarker) throw IOException("JPEG 缺少结束标记,文件可能被截断")
"JPEG"
}
isPng(bytes) -> {
if (
bytes.size < 20 ||
bytes[bytes.size - 8] != 'I'.code.toByte() ||
bytes[bytes.size - 7] != 'E'.code.toByte() ||
bytes[bytes.size - 6] != 'N'.code.toByte() ||
bytes[bytes.size - 5] != 'D'.code.toByte()
) {
throw IOException("PNG 缺少 IEND,文件可能被截断")
}
"PNG"
}
isWebp(bytes) -> {
val declaredSize = uint32Le(bytes, 4) + 8L
if (declaredSize != bytes.size.toLong()) {
throw IOException("WebP 文件长度异常: declared=$declaredSize, actual=${bytes.size}")
}
"WEBP"
}
isGif(bytes) -> {
if (byteAt(bytes, bytes.lastIndex) != 0x3B) {
throw IOException("GIF 缺少结束标记,文件可能被截断")
}
"GIF"
}
isBmp(bytes) -> {
val declaredSize = uint32Le(bytes, 2)
if (declaredSize != bytes.size.toLong()) {
throw IOException("BMP 文件长度异常: declared=$declaredSize, actual=${bytes.size}")
}
"BMP"
}
isIsoBaseMedia(bytes) -> "HEIF/AVIF"
else -> throw IOException("不支持或无法识别的图片格式")
}
}
private fun isJpeg(bytes: ByteArray): Boolean =
bytes.size >= 4 && byteAt(bytes, 0) == 0xFF && byteAt(bytes, 1) == 0xD8
private fun isPng(bytes: ByteArray): Boolean =
bytes.size >= 8 &&
byteAt(bytes, 0) == 0x89 && bytes[1] == 'P'.code.toByte() &&
bytes[2] == 'N'.code.toByte() && bytes[3] == 'G'.code.toByte() &&
byteAt(bytes, 4) == 0x0D && byteAt(bytes, 5) == 0x0A &&
byteAt(bytes, 6) == 0x1A && byteAt(bytes, 7) == 0x0A
private fun isWebp(bytes: ByteArray): Boolean =
bytes.size >= 12 && ascii(bytes, 0, 4) == "RIFF" && ascii(bytes, 8, 4) == "WEBP"
private fun isGif(bytes: ByteArray): Boolean =
bytes.size >= 6 && (ascii(bytes, 0, 6) == "GIF87a" || ascii(bytes, 0, 6) == "GIF89a")
private fun isBmp(bytes: ByteArray): Boolean =
bytes.size >= 14 && bytes[0] == 'B'.code.toByte() && bytes[1] == 'M'.code.toByte()
private fun isIsoBaseMedia(bytes: ByteArray): Boolean =
bytes.size >= 12 && ascii(bytes, 4, 4) == "ftyp"
private fun ascii(bytes: ByteArray, offset: Int, length: Int): String =
bytes.copyOfRange(offset, offset + length).toString(Charsets.US_ASCII)
private fun byteAt(bytes: ByteArray, index: Int): Int = bytes[index].toInt() and 0xFF
private fun uint16Le(bytes: ByteArray, offset: Int): Int =
byteAt(bytes, offset) or (byteAt(bytes, offset + 1) shl 8)
private fun int32Le(bytes: ByteArray, offset: Int): Int =
byteAt(bytes, offset) or
(byteAt(bytes, offset + 1) shl 8) or
(byteAt(bytes, offset + 2) shl 16) or
(byteAt(bytes, offset + 3) shl 24)
private fun uint32Le(bytes: ByteArray, offset: Int): Long =
int32Le(bytes, offset).toLong() and 0xFFFF_FFFFL
private fun sha256(bytes: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(bytes).toHex()
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
FileInputStream(file).use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val count = input.read(buffer)
if (count < 0) break
if (count > 0) digest.update(buffer, 0, count)
}
}
return digest.digest().toHex()
}
private fun ByteArray.toHex(): String = joinToString(separator = "") { byte -> "%02x".format(byte) }
}
@@ -5,7 +5,6 @@ import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.Paint
import com.yzx.kiosk.App import com.yzx.kiosk.App
import com.yzx.kiosk.datastore.AppStoreDataSource import com.yzx.kiosk.datastore.AppStoreDataSource
import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.LogUtils
@@ -23,8 +22,11 @@ import jp.co.dnpLib.print.PrintManager
import jp.co.dnpLib.print.PrintQueue import jp.co.dnpLib.print.PrintQueue
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.IOException
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.math.min import kotlin.math.min
@@ -40,10 +42,16 @@ class PrinterService(
) { ) {
companion object { companion object {
private const val TAG = "PrinterService" private const val TAG = "PrinterService"
// DNP BmpUtil 在 300 DPI 下会将 RX1 输入画布编码为 1844x1240 BMP。
// 1840x1240 仍是上层排版画布尺寸,不应用它校验 SDK 的最终文件头。
private const val RX1_ENCODED_BMP_WIDTH_300_DPI = 1844
private const val RX1_ENCODED_BMP_HEIGHT_300_DPI = 1240
} }
private var mDnpPhotoPrint: DNPPhotoPrint? = null private var mDnpPhotoPrint: DNPPhotoPrint? = null
private val printMutex = Mutex()
/** /**
* 初始化打印机(耗时操作,需在IO线程调用) * 初始化打印机(耗时操作,需在IO线程调用)
* @return 是否初始化成功 * @return 是否初始化成功
@@ -77,14 +85,14 @@ class PrinterService(
false false
} }
} }
/** /**
* 获取当前打印机ID * 获取当前打印机ID
*/ */
fun getPrintId(): Int { fun getPrintId(): Int {
return appStoreDataSource.getPrintId() return appStoreDataSource.getPrintId()
} }
/** /**
* 准备打印图片:保持原比例,全内容居中,空白区域背景为白色,宽图可调整方向 * 准备打印图片:保持原比例,全内容居中,空白区域背景为白色,宽图可调整方向
* @param originalBitmap 原始图片(不会被回收) * @param originalBitmap 原始图片(不会被回收)
@@ -96,14 +104,14 @@ class PrinterService(
var workingBitmap: Bitmap? = null // 用于处理的临时bitmap var workingBitmap: Bitmap? = null // 用于处理的临时bitmap
var rotatedBitmap: Bitmap? = null // 旋转后的bitmap(如果需要) var rotatedBitmap: Bitmap? = null // 旋转后的bitmap(如果需要)
var scaledBitmap: Bitmap? = null // 缩放后的bitmap var scaledBitmap: Bitmap? = null // 缩放后的bitmap
try { try {
val originalWidth = originalBitmap.width val originalWidth = originalBitmap.width
val originalHeight = originalBitmap.height val originalHeight = originalBitmap.height
val isWideImage = originalWidth > originalHeight // 是否为宽图 val isWideImage = originalWidth > originalHeight // 是否为宽图
LogUtils.d(TAG, "原始图片: ${originalWidth}x${originalHeight}, 是否宽图: $isWideImage") LogUtils.d(TAG, "原始图片: ${originalWidth}x${originalHeight}, 是否宽图: $isWideImage")
// 判断是否需要旋转 // 判断是否需要旋转
// RX1打印机需要横向图片(1840x1240),QW410需要纵向图片(1266x1836) // RX1打印机需要横向图片(1840x1240),QW410需要纵向图片(1266x1836)
val needRotate = if (printId != PrintManager.EPrinter.QW410_DEF.id) { val needRotate = if (printId != PrintManager.EPrinter.QW410_DEF.id) {
@@ -113,7 +121,7 @@ class PrinterService(
// QW410打印机:如果图片是横向的,需要旋转90度 // QW410打印机:如果图片是横向的,需要旋转90度
isWideImage isWideImage
} }
// 如果需要旋转,先旋转图片 // 如果需要旋转,先旋转图片
if (needRotate) { if (needRotate) {
LogUtils.d(TAG, "旋转图片90度以适应打印方向") LogUtils.d(TAG, "旋转图片90度以适应打印方向")
@@ -128,21 +136,21 @@ class PrinterService(
} else { } else {
workingBitmap = originalBitmap workingBitmap = originalBitmap
} }
// 计算缩放比例,保持原比例 // 计算缩放比例,保持原比例
val scale = min( val scale = min(
printSize.width.toFloat() / workingBitmap.width, printSize.width.toFloat() / workingBitmap.width,
printSize.height.toFloat() / workingBitmap.height printSize.height.toFloat() / workingBitmap.height
) )
val scaledWidth = (workingBitmap.width * scale).toInt() val scaledWidth = (workingBitmap.width * scale).toInt()
val scaledHeight = (workingBitmap.height * scale).toInt() val scaledHeight = (workingBitmap.height * scale).toInt()
LogUtils.d(TAG, "缩放比例: $scale, 缩放后尺寸: ${scaledWidth}x${scaledHeight}") LogUtils.d(TAG, "缩放比例: $scale, 缩放后尺寸: ${scaledWidth}x${scaledHeight}")
// 创建缩放后的图片 // 创建缩放后的图片
scaledBitmap = Bitmap.createScaledBitmap(workingBitmap, scaledWidth, scaledHeight, true) scaledBitmap = Bitmap.createScaledBitmap(workingBitmap, scaledWidth, scaledHeight, true)
// 创建白色背景的画布 // 创建白色背景的画布
val outputBitmap = Bitmap.createBitmap( val outputBitmap = Bitmap.createBitmap(
printSize.width, printSize.width,
@@ -150,23 +158,23 @@ class PrinterService(
Bitmap.Config.ARGB_8888 Bitmap.Config.ARGB_8888
) )
val canvas = Canvas(outputBitmap) val canvas = Canvas(outputBitmap)
// 填充白色背景 // 填充白色背景
canvas.drawColor(Color.WHITE) canvas.drawColor(Color.WHITE)
// 计算居中位置 // 计算居中位置
val left = (printSize.width - scaledWidth) / 2f val left = (printSize.width - scaledWidth) / 2f
val top = (printSize.height - scaledHeight) / 2f val top = (printSize.height - scaledHeight) / 2f
LogUtils.d(TAG, "居中位置: left=$left, top=$top") LogUtils.d(TAG, "居中位置: left=$left, top=$top")
// 在画布上绘制缩放后的图片(居中) // 在画布上绘制缩放后的图片(居中)
canvas.drawBitmap(scaledBitmap, left, top, null) canvas.drawBitmap(scaledBitmap, left, top, null)
// 回收临时bitmap // 回收临时bitmap
scaledBitmap?.let { if (!it.isRecycled) it.recycle() } scaledBitmap?.let { if (!it.isRecycled) it.recycle() }
rotatedBitmap?.let { if (!it.isRecycled) it.recycle() } rotatedBitmap?.let { if (!it.isRecycled) it.recycle() }
return outputBitmap return outputBitmap
} catch (e: Exception) { } catch (e: Exception) {
LogUtils.e(TAG, "准备打印图片失败: ${e.message}") LogUtils.e(TAG, "准备打印图片失败: ${e.message}")
@@ -176,34 +184,51 @@ class PrinterService(
throw e throw e
} }
} }
/** /**
* 打印图片(耗时操作) * 打印图片(耗时操作)
* @param originalBitmap 要打印的原始图片 * @param originalBitmap 要打印的原始图片
* @param callback 打印结果回调 * @param callback 打印结果回调
*/ */
suspend fun startPrint(originalBitmap: Bitmap?, callback: PrintCallback) = withContext(Dispatchers.IO) { suspend fun startPrint(originalBitmap: Bitmap?, callback: PrintCallback) = printMutex.withLock {
startPrintInternal(originalBitmap, callback)
}
private suspend fun startPrintInternal(
originalBitmap: Bitmap?,
callback: PrintCallback
) = withContext(Dispatchers.IO) {
if (originalBitmap == null) { if (originalBitmap == null) {
LogUtils.e(TAG, "获取图片失败") LogUtils.e(TAG, "获取图片失败")
callback.onPrintResult(false,"获取图片失败") callback.onPrintResult(false,"获取图片失败")
return@withContext return@withContext
} }
if (
originalBitmap.isRecycled ||
originalBitmap.width <= 0 ||
originalBitmap.height <= 0 ||
originalBitmap.config == Bitmap.Config.HARDWARE
) {
LogUtils.e(TAG, "图片 Bitmap 无效或为硬件 Bitmap,禁止打印")
callback.onPrintResult(false, "图片数据无效")
return@withContext
}
LogUtils.e(TAG, "获取图片成功") LogUtils.e(TAG, "获取图片成功")
// 在开始打印前,检查打印机是否在线 // 在开始打印前,检查打印机是否在线
if (printStatusManager.isOffline()) { if (printStatusManager.isOffline()) {
LogUtils.e(TAG, "打印机离线,无法打印") LogUtils.e(TAG, "打印机离线,无法打印")
callback.onPrintResult(false, "打印机离线,无法打印") callback.onPrintResult(false, "打印机离线,无法打印")
return@withContext return@withContext
} }
// 设置打印状态为正在打印 // 设置打印状态为正在打印
printStatusManager.setPrinting() printStatusManager.setPrinting()
try { try {
val printQueue = PrintQueue(context) val printQueue = PrintQueue(context)
val printId = appStoreDataSource.getPrintId() val printId = appStoreDataSource.getPrintId()
// 再次检查打印机ID是否有效(如果打印机未初始化,printId 可能为 0) // 再次检查打印机ID是否有效(如果打印机未初始化,printId 可能为 0)
if (printId == 0) { if (printId == 0) {
LogUtils.e(TAG, "打印机未初始化,printId=0") LogUtils.e(TAG, "打印机未初始化,printId=0")
@@ -211,10 +236,10 @@ class PrinterService(
callback.onPrintResult(false, "打印机未初始化") callback.onPrintResult(false, "打印机未初始化")
return@withContext return@withContext
} }
LogUtils.d(TAG, "开始打印,打印机ID: $printId") LogUtils.d(TAG, "开始打印,打印机ID: $printId")
LogUtils.d(TAG, "原始图片尺寸: ${originalBitmap.width} x ${originalBitmap.height}") LogUtils.d(TAG, "原始图片尺寸: ${originalBitmap.width} x ${originalBitmap.height}")
// 获取打印尺寸 // 获取打印尺寸
val printSize = if (printId != PrintManager.EPrinter.QW410_DEF.id) { val printSize = if (printId != PrintManager.EPrinter.QW410_DEF.id) {
PRINTSIZE.RX1 PRINTSIZE.RX1
@@ -222,19 +247,24 @@ class PrinterService(
PRINTSIZE.QW410 PRINTSIZE.QW410
} }
LogUtils.d(TAG, "打印尺寸: ${printSize.width} x ${printSize.height}") LogUtils.d(TAG, "打印尺寸: ${printSize.width} x ${printSize.height}")
// 准备打印图片(保持原比例,居中,白色背景) // 准备打印图片(保持原比例,居中,白色背景)
val outputBitmap: Bitmap var outputBitmap: Bitmap? = null
val outFile = File( val outFile = File(
context.getExternalFilesDir(null), context.getExternalFilesDir(null),
"ProcessedPhotos/${System.currentTimeMillis()}_print.bmp" "ProcessedPhotos/${System.currentTimeMillis()}_print.bmp"
) )
outFile.parentFile?.mkdirs() val outputDirectory = outFile.parentFile
if (outputDirectory == null || (!outputDirectory.exists() && !outputDirectory.mkdirs())) {
printStatusManager.setIdle()
callback.onPrintResult(false, "无法创建打印文件目录")
return@withContext
}
try { try {
outputBitmap = preparePrintBitmap(originalBitmap, printSize, printId) outputBitmap = preparePrintBitmap(originalBitmap, printSize, printId)
if (printId != PrintManager.EPrinter.QW410_DEF.id) { val saved = if (printId != PrintManager.EPrinter.QW410_DEF.id) {
BmpUtil.save(outputBitmap, outFile.absolutePath, EResolution.RESO300.mValue) BmpUtil.save(outputBitmap, outFile.absolutePath, EResolution.RESO300.mValue)
} else { } else {
AndroidBmpUtil.save( AndroidBmpUtil.save(
@@ -244,12 +274,45 @@ class PrinterService(
PRINTSIZE.QW410.height PRINTSIZE.QW410.height
) )
} }
if (!saved) {
throw IOException("BMP 保存接口返回失败")
}
val expectedBmpWidth: Int
val expectedBmpHeight: Int
if (printId != PrintManager.EPrinter.QW410_DEF.id) {
expectedBmpWidth = RX1_ENCODED_BMP_WIDTH_300_DPI
expectedBmpHeight = RX1_ENCODED_BMP_HEIGHT_300_DPI
} else {
expectedBmpWidth = printSize.width
expectedBmpHeight = printSize.height
}
val validation = PrintImageIntegrityValidator.validateBmp(
file = outFile,
expectedWidth = expectedBmpWidth,
expectedHeight = expectedBmpHeight
)
LogUtils.d(
TAG,
"打印 BMP 校验成功 - path=${outFile.absolutePath}, " +
"size=${validation.width}x${validation.height}, " +
"bpp=${validation.bitsPerPixel}, bytes=${validation.fileSize}, " +
"sha256=${validation.sha256.take(16)}"
)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() LogUtils.e(TAG, "图片处理或 BMP 完整性校验失败: ${e.message}")
callback.onPrintResult(false,"图片处理失败: ${e.message}") outputBitmap?.let { bitmap ->
if (!bitmap.isRecycled) bitmap.recycle()
}
printStatusManager.setIdle()
callback.onPrintResult(false, "图片处理失败: ${e.message}")
return@withContext return@withContext
} }
val validatedOutputBitmap = checkNotNull(outputBitmap)
// 创建打印任务 // 创建打印任务
val job = PrintJob( val job = PrintJob(
outFile.absolutePath, outFile.absolutePath,
@@ -263,14 +326,14 @@ class PrinterService(
1 1
) )
printQueue.addJob(job) printQueue.addJob(job)
if (printId != PrintManager.EPrinter.QW410_DEF.id) { if (printId != PrintManager.EPrinter.QW410_DEF.id) {
// 打印完成,设置为空闲 // 打印完成,设置为空闲
printStatusManager.setIdle() printStatusManager.setIdle()
// 回收 outputBitmap // 回收 outputBitmap
try { try {
if (!outputBitmap.isRecycled) { if (!validatedOutputBitmap.isRecycled) {
outputBitmap.recycle() validatedOutputBitmap.recycle()
LogUtils.d(TAG, "outputBitmap 已回收") LogUtils.d(TAG, "outputBitmap 已回收")
} }
} catch (ex: Exception) { } catch (ex: Exception) {
@@ -285,8 +348,8 @@ class PrinterService(
printStatusManager.setIdle() printStatusManager.setIdle()
// 回收 outputBitmap // 回收 outputBitmap
try { try {
if (!outputBitmap.isRecycled) { if (!validatedOutputBitmap.isRecycled) {
outputBitmap.recycle() validatedOutputBitmap.recycle()
LogUtils.d(TAG, "outputBitmap 已回收") LogUtils.d(TAG, "outputBitmap 已回收")
} }
} catch (ex: Exception) { } catch (ex: Exception) {
@@ -294,7 +357,7 @@ class PrinterService(
} }
callback.onPrintResult(true) callback.onPrintResult(true)
} }
} catch (e: Exception) { } catch (e: Exception) {
LogUtils.e(TAG, "打印失败: ${e.message}") 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
}
}
}
@@ -1,5 +1,6 @@
package com.yzx.kiosk.ui.face.view package com.yzx.kiosk.ui.face.view
import android.graphics.Bitmap
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -24,6 +25,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -35,6 +37,7 @@ import coil.compose.AsyncImage
import coil.compose.SubcomposeAsyncImage import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent import coil.compose.SubcomposeAsyncImageContent
import coil.compose.SubcomposeAsyncImageScope import coil.compose.SubcomposeAsyncImageScope
import coil.request.ImageRequest
import com.yzx.kiosk.R import com.yzx.kiosk.R
import com.yzx.kiosk.component.appbar.AppTitleBar import com.yzx.kiosk.component.appbar.AppTitleBar
import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding
@@ -46,7 +49,7 @@ import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionResultViewModel
import com.yzx.kiosk.ui.upload.view.PhotoPreviewDialog import com.yzx.kiosk.ui.upload.view.PhotoPreviewDialog
import com.yzx.kiosk.ui.upload.view.RetryableAsyncImage import com.yzx.kiosk.ui.upload.view.RetryableAsyncImage
import com.yzx.kiosk.utils.QrCodeUtils import com.yzx.kiosk.utils.QrCodeUtils
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.collect
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
@@ -60,25 +63,32 @@ fun FaceRecognitionResultScreen(
viewModel.initPhotoList(results) viewModel.initPhotoList(results)
} }
} }
val photoList by viewModel.photoList.collectAsState() val photoList by viewModel.photoList.collectAsState()
val selectedPhotos by viewModel.selectedPhotos.collectAsState() val selectedPhotos by viewModel.selectedPhotos.collectAsState()
val pricePerPhoto by viewModel.pricePerPhoto.collectAsState() val pricePerPhoto by viewModel.pricePerPhoto.collectAsState()
val totalPrice by viewModel.totalPrice.collectAsState() val totalPrice by viewModel.totalPrice.collectAsState()
val imageLoadStates by viewModel.imageLoadStates.collectAsState()
val selectedCount = selectedPhotos.size val selectedCount = selectedPhotos.size
val isAllSelected = photoList.isNotEmpty() && selectedPhotos.size == photoList.size val isAllSelected = photoList.isNotEmpty() && selectedPhotos.size == photoList.size
// 预览弹框状态 // 预览弹框状态
var showPreviewDialog by remember { mutableStateOf(false) } var showPreviewDialog by remember { mutableStateOf(false) }
var previewInitialIndex by remember { mutableIntStateOf(0) } var previewInitialIndex by remember { mutableIntStateOf(0) }
// 支付弹框状态 // 支付弹框状态
var showPayDialog by remember { mutableStateOf(false) } var showPayDialog by remember { mutableStateOf(false) }
val payQrCodeUrl by viewModel.payQrCodeUrl.collectAsState() val payQrCodeUrl by viewModel.payQrCodeUrl.collectAsState()
LaunchedEffect(viewModel) {
viewModel.dismissPayDialogEvents.collect {
showPayDialog = false
}
}
FullScreenMode() FullScreenMode()
AppScaffold( AppScaffold(
topBar = { topBar = {
Column { Column {
@@ -146,9 +156,9 @@ fun FaceRecognitionResultScreen(
color = Color(0xFF000000) color = Color(0xFF000000)
) )
} }
Button( Button(
onClick = { onClick = {
viewModel.getPayUrl { url -> viewModel.getPayUrl { url ->
showPayDialog = true showPayDialog = true
} }
@@ -169,7 +179,7 @@ fun FaceRecognitionResultScreen(
} }
} }
} }
// 全选行 // 全选行
Row( Row(
modifier = Modifier modifier = Modifier
@@ -200,9 +210,9 @@ fun FaceRecognitionResultScreen(
) )
} }
} }
Spacer(modifier = Modifier.width(18.dp)) Spacer(modifier = Modifier.width(18.dp))
Text( Text(
text = "全选", text = "全选",
fontSize = 24.sp, fontSize = 24.sp,
@@ -214,7 +224,7 @@ fun FaceRecognitionResultScreen(
color = Color.Black color = Color.Black
) )
} }
// 图片瀑布流列表 // 图片瀑布流列表
LazyVerticalStaggeredGrid( LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2), columns = StaggeredGridCells.Fixed(2),
@@ -224,12 +234,15 @@ fun FaceRecognitionResultScreen(
horizontalArrangement = Arrangement.spacedBy(18.dp), horizontalArrangement = Arrangement.spacedBy(18.dp),
verticalItemSpacing = 18.dp verticalItemSpacing = 18.dp
) { ) {
itemsIndexed(photoList) { index, photo -> itemsIndexed(
items = photoList,
key = { _, photo -> photo.id }
) { _, photo ->
val isSelected = selectedPhotos.contains(photo.url) val isSelected = selectedPhotos.contains(photo.url)
val imageLoadStates by viewModel.imageLoadStates.collectAsState()
val loadState = imageLoadStates[photo.url] ?: FaceRecognitionResultViewModel.ImageLoadState() val loadState = imageLoadStates[photo.url] ?: FaceRecognitionResultViewModel.ImageLoadState()
FaceRecognitionPhotoItem( FaceRecognitionPhotoItem(
photoId = photo.id,
photoUrl = photo.url, photoUrl = photo.url,
aspectRatio = photo.aspectRatio, aspectRatio = photo.aspectRatio,
isSelected = isSelected, isSelected = isSelected,
@@ -246,13 +259,16 @@ fun FaceRecognitionResultScreen(
}, },
onLoadError = { viewModel.handleImageLoadError(photo.url) }, onLoadError = { viewModel.handleImageLoadError(photo.url) },
onLoadSuccess = { viewModel.handleImageLoadSuccess(photo.url) }, onLoadSuccess = { viewModel.handleImageLoadSuccess(photo.url) },
onImageDimensionsResolved = { width, height ->
viewModel.updatePhotoAspectRatio(photo.id, width, height)
},
onRetryClick = { viewModel.retryLoadImage(photo.url) } onRetryClick = { viewModel.retryLoadImage(photo.url) }
) )
} }
} }
} }
} }
// 预览弹框 - 仅预览已选择的图片 // 预览弹框 - 仅预览已选择的图片
val selectedPhotoUrls = photoList.filter { selectedPhotos.contains(it.url) }.map { it.url } val selectedPhotoUrls = photoList.filter { selectedPhotos.contains(it.url) }.map { it.url }
if (showPreviewDialog && selectedPhotoUrls.isNotEmpty()) { if (showPreviewDialog && selectedPhotoUrls.isNotEmpty()) {
@@ -262,18 +278,22 @@ fun FaceRecognitionResultScreen(
onDismiss = { showPreviewDialog = false } onDismiss = { showPreviewDialog = false }
) )
} }
// 支付弹框 // 支付弹框
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) { if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) {
val selectedUrls = photoList.filter { selectedPhotos.contains(it.url) }.map { it.url } DisposableEffect(payQrCodeUrl) {
viewModel.startPayStatusPolling()
onDispose {
viewModel.stopPayStatusPolling()
}
}
WechatPayDialog( WechatPayDialog(
qrCodeUrl = payQrCodeUrl!!, qrCodeUrl = payQrCodeUrl!!,
totalPrice = totalPrice, totalPrice = totalPrice,
onDismiss = { showPayDialog = false }, onDismiss = {
onPaySuccess = {
showPayDialog = false showPayDialog = false
// 支付成功,等待 WebSocket 消息(code=5)触发跳转到支付成功页面 viewModel.onPayDialogDismissed()
// 跳转逻辑由 FaceRecognitionResultViewModel 中的 WebSocket 监听处理
} }
) )
} }
@@ -281,6 +301,7 @@ fun FaceRecognitionResultScreen(
@Composable @Composable
fun FaceRecognitionPhotoItem( fun FaceRecognitionPhotoItem(
photoId: Int,
photoUrl: String, photoUrl: String,
aspectRatio: Float, aspectRatio: Float,
isSelected: Boolean, isSelected: Boolean,
@@ -289,6 +310,7 @@ fun FaceRecognitionPhotoItem(
onPreviewClick: () -> Unit, onPreviewClick: () -> Unit,
onLoadError: () -> Unit = {}, onLoadError: () -> Unit = {},
onLoadSuccess: () -> Unit = {}, onLoadSuccess: () -> Unit = {},
onImageDimensionsResolved: (width: Int, height: Int) -> Unit = { _, _ -> },
onRetryClick: () -> Unit = {} onRetryClick: () -> Unit = {}
) { ) {
// 确保 aspectRatio 是有效值 // 确保 aspectRatio 是有效值
@@ -297,7 +319,7 @@ fun FaceRecognitionPhotoItem(
} else { } else {
1f // 默认1:1 1f // 默认1:1
} }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -306,11 +328,20 @@ fun FaceRecognitionPhotoItem(
.clickable { onToggleSelection() } .clickable { onToggleSelection() }
) { ) {
// 图片 - 按原比例显示 // 图片 - 按原比例显示
var lastErrorState by remember { mutableStateOf(false) } var lastErrorState by remember(photoId, photoUrl) { mutableStateOf(false) }
var lastSuccessState by remember { mutableStateOf(false) } var lastSuccessState by remember(photoId, photoUrl) { mutableStateOf(false) }
val context = LocalContext.current
val imageRequest = remember(context, photoUrl) {
ImageRequest.Builder(context)
.data(photoUrl)
.allowHardware(false)
.bitmapConfig(Bitmap.Config.ARGB_8888)
.crossfade(false)
.build()
}
SubcomposeAsyncImage( SubcomposeAsyncImage(
model = photoUrl, model = imageRequest,
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit contentScale = ContentScale.Fit
@@ -339,16 +370,16 @@ fun FaceRecognitionPhotoItem(
onLoadError() onLoadError()
} }
} }
// 如果已经重试3次,显示加载失败,支持点击重试 // 如果已经重试3次,显示加载失败,支持点击重试
if (loadState.isFailed) { if (loadState.isFailed) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color(0xFFF5F5F5)) .background(Color(0xFFF5F5F5))
.clickable { .clickable {
lastErrorState = false lastErrorState = false
onRetryClick() onRetryClick()
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@@ -390,6 +421,11 @@ fun FaceRecognitionPhotoItem(
if (state is coil.compose.AsyncImagePainter.State.Success && !lastSuccessState) { if (state is coil.compose.AsyncImagePainter.State.Success && !lastSuccessState) {
lastSuccessState = true lastSuccessState = true
lastErrorState = false lastErrorState = false
val drawable = state.result.drawable
onImageDimensionsResolved(
drawable.intrinsicWidth,
drawable.intrinsicHeight
)
onLoadSuccess() onLoadSuccess()
} }
} }
@@ -397,7 +433,7 @@ fun FaceRecognitionPhotoItem(
} }
} }
} }
// 选中遮罩 // 选中遮罩
Box( Box(
modifier = Modifier modifier = Modifier
@@ -406,7 +442,7 @@ fun FaceRecognitionPhotoItem(
if (isSelected) Color.Black.copy(alpha = 0.3f) else Color.Transparent if (isSelected) Color.Black.copy(alpha = 0.3f) else Color.Transparent
) )
) )
// 右上角复选框 // 右上角复选框
Box( Box(
modifier = Modifier modifier = Modifier
@@ -431,7 +467,7 @@ fun FaceRecognitionPhotoItem(
) )
} }
} }
// 点击预览文字(选中时显示)- 点击打开预览弹框 // 点击预览文字(选中时显示)- 点击打开预览弹框
if (isSelected) { if (isSelected) {
Text( Text(
@@ -453,8 +489,7 @@ fun FaceRecognitionPhotoItem(
fun WechatPayDialog( fun WechatPayDialog(
qrCodeUrl: String, qrCodeUrl: String,
totalPrice: String, totalPrice: String,
onDismiss: () -> Unit, onDismiss: () -> Unit
onPaySuccess: () -> Unit
) { ) {
// 使用接口返回的URL生成支付二维码 // 使用接口返回的URL生成支付二维码
val qrCodeBitmap = remember(qrCodeUrl) { val qrCodeBitmap = remember(qrCodeUrl) {
@@ -466,7 +501,7 @@ fun WechatPayDialog(
cornerRadius = 0f cornerRadius = 0f
) )
} }
Dialog( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties( properties = DialogProperties(
@@ -498,7 +533,7 @@ fun WechatPayDialog(
color = Color.Black, color = Color.Black,
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
) )
// 关闭按钮 // 关闭按钮
Box( Box(
modifier = Modifier modifier = Modifier
@@ -517,9 +552,9 @@ fun WechatPayDialog(
) )
} }
} }
Spacer(modifier = Modifier.height(36.dp)) Spacer(modifier = Modifier.height(36.dp))
// 二维码 // 二维码
Box( Box(
modifier = Modifier modifier = Modifier
@@ -533,9 +568,9 @@ fun WechatPayDialog(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
Spacer(modifier = Modifier.height(22.dp)) Spacer(modifier = Modifier.height(22.dp))
// 提示文字 // 提示文字
Text( Text(
text = "请使用微信扫描二维码支付", text = "请使用微信扫描二维码支付",
@@ -2,8 +2,6 @@ package com.yzx.kiosk.ui.face.view
import android.Manifest import android.Manifest
import android.graphics.Bitmap import android.graphics.Bitmap
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.camera.core.* import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
@@ -39,9 +37,6 @@ import com.yzx.kiosk.component.scaffold.AppScaffold
import com.yzx.kiosk.ui.common.view.FullScreenMode import com.yzx.kiosk.ui.common.view.FullScreenMode
import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionViewModel import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionViewModel
import com.yzx.kiosk.ui.face.viewmodel.RecognitionStatus import com.yzx.kiosk.ui.face.viewmodel.RecognitionStatus
import java.util.concurrent.Executor
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@Composable @Composable
@@ -56,6 +51,7 @@ fun FaceRecognitionScreen(
val recognitionStatus by viewModel.recognitionStatus.collectAsState() val recognitionStatus by viewModel.recognitionStatus.collectAsState()
val capturedImage by viewModel.capturedImage.collectAsState() val capturedImage by viewModel.capturedImage.collectAsState()
val isRecognizing by viewModel.isRecognizing.collectAsState() val isRecognizing by viewModel.isRecognizing.collectAsState()
val isCaptureInProgress by viewModel.isCaptureInProgress.collectAsState()
// 相机权限 // 相机权限
val permissionsState = rememberMultiplePermissionsState( val permissionsState = rememberMultiplePermissionsState(
@@ -103,6 +99,9 @@ fun FaceRecognitionScreen(
onImageCaptureReady = { imageCapture -> onImageCaptureReady = { imageCapture ->
viewModel.setImageCapture(imageCapture) viewModel.setImageCapture(imageCapture)
}, },
onImageCaptureReleased = { imageCapture ->
viewModel.clearImageCapture(imageCapture)
},
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@@ -201,7 +200,7 @@ fun FaceRecognitionScreen(
} }
} }
// 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照") // 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照")
if (!isRecognizing) { if (!isRecognizing && !isCaptureInProgress) {
Button( Button(
onClick = { onClick = {
if (recognitionStatus == RecognitionStatus.FAILED) { if (recognitionStatus == RecognitionStatus.FAILED) {
@@ -309,50 +308,62 @@ fun FaceRecognitionScreen(
@Composable @Composable
fun CameraPreview( fun CameraPreview(
onImageCaptureReady: (ImageCapture) -> Unit, onImageCaptureReady: (ImageCapture) -> Unit,
onImageCaptureReleased: (ImageCapture) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val currentOnImageCaptureReady by rememberUpdatedState(onImageCaptureReady)
val currentOnImageCaptureReleased by rememberUpdatedState(onImageCaptureReleased)
val previewView = remember(context) { PreviewView(context) }
val preview = remember { Preview.Builder().build() }
val imageCapture = remember {
ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
}
AndroidView( AndroidView(
factory = { ctx -> factory = { previewView },
val previewView = PreviewView(ctx)
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
// 创建预览
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// 创建图像捕获
val imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
// 通知ViewModel ImageCapture已准备好
onImageCaptureReady(imageCapture)
// 绑定到生命周期
val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageCapture
)
} catch (e: Exception) {
e.printStackTrace()
}
}, ContextCompat.getMainExecutor(ctx))
previewView
},
modifier = modifier modifier = modifier
) )
DisposableEffect(context, lifecycleOwner, preview, imageCapture) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
val executor = ContextCompat.getMainExecutor(context)
var cameraProvider: ProcessCameraProvider? = null
var disposed = false
preview.setSurfaceProvider(previewView.surfaceProvider)
cameraProviderFuture.addListener({
try {
val resolvedProvider = cameraProviderFuture.get()
if (disposed) {
resolvedProvider.unbind(preview, imageCapture)
return@addListener
}
cameraProvider = resolvedProvider
resolvedProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_FRONT_CAMERA,
preview,
imageCapture,
)
currentOnImageCaptureReady(imageCapture)
} catch (e: Exception) {
e.printStackTrace()
}
}, executor)
onDispose {
disposed = true
currentOnImageCaptureReleased(imageCapture)
preview.setSurfaceProvider(null)
cameraProvider?.unbind(preview, imageCapture)
}
}
} }
@@ -0,0 +1,26 @@
package com.yzx.kiosk.ui.face.viewmodel
import com.yzx.kiosk.network.model.response.PaySuccessMessageResponse
internal enum class FacePayStatusDecision {
CONTINUE_POLLING,
COMPLETE_PAYMENT,
STOP_WITH_TERMINAL_STATUS
}
internal fun decideFacePayStatus(orderStatus: Int?): FacePayStatusDecision = when (orderStatus) {
30 -> FacePayStatusDecision.COMPLETE_PAYMENT
40, 50, 60 -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS
else -> FacePayStatusDecision.CONTINUE_POLLING
}
internal fun isValidFacePaySuccessMessage(
expectedOrderNumber: String,
message: PaySuccessMessageResponse?
): Boolean {
val paymentData = message?.data ?: return false
return message.type == 5 &&
paymentData.orderNumber == expectedOrderNumber &&
paymentData.captureType == 2 &&
!paymentData.imageIds.isNullOrEmpty()
}
@@ -1,16 +1,12 @@
package com.yzx.kiosk.ui.face.viewmodel package com.yzx.kiosk.ui.face.viewmodel
import android.graphics.ImageDecoder
import android.os.Build
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Size
import com.yzx.kiosk.App
import com.yzx.kiosk.base.BaseViewModel import com.yzx.kiosk.base.BaseViewModel
import com.yzx.kiosk.datastore.AppState import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.navigation.AppNavigator import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.network.model.request.GetPayUrlRequest import com.yzx.kiosk.network.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
import com.yzx.kiosk.network.model.request.VerifyResultRequest import com.yzx.kiosk.network.model.request.VerifyResultRequest
import com.yzx.kiosk.network.model.response.FaceSearchResult import com.yzx.kiosk.network.model.response.FaceSearchResult
import com.yzx.kiosk.network.repository.NetWorkRepository import com.yzx.kiosk.network.repository.NetWorkRepository
@@ -25,15 +21,21 @@ import com.yzx.kiosk.datastore.AppStoreDataSource
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.LogUtils
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -49,53 +51,67 @@ class FaceRecognitionResultViewModel @Inject constructor(
) { ) {
// 原始结果列表(用于获取图片id) // 原始结果列表(用于获取图片id)
private var originalResults: List<FaceSearchResult> = emptyList() private var originalResults: List<FaceSearchResult> = emptyList()
// URL到ID的映射 // URL到ID的映射
private val urlToIdMap = mutableMapOf<String, Int>() private val urlToIdMap = mutableMapOf<String, Int>()
// 图片列表 // 图片列表
private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList()) private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList())
val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow() val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow()
// 已选中的图片URL(用于UI显示) // 已选中的图片URL(用于UI显示)
private val _selectedPhotos = MutableStateFlow<Set<String>>(emptySet()) private val _selectedPhotos = MutableStateFlow<Set<String>>(emptySet())
val selectedPhotos: StateFlow<Set<String>> = _selectedPhotos.asStateFlow() val selectedPhotos: StateFlow<Set<String>> = _selectedPhotos.asStateFlow()
// 单价 // 单价
private val _pricePerPhoto = MutableStateFlow<String>("--") private val _pricePerPhoto = MutableStateFlow<String>("--")
val pricePerPhoto: StateFlow<String> = _pricePerPhoto.asStateFlow() val pricePerPhoto: StateFlow<String> = _pricePerPhoto.asStateFlow()
// 总价 // 总价
private val _totalPrice = MutableStateFlow<String>("--") private val _totalPrice = MutableStateFlow<String>("--")
val totalPrice: StateFlow<String> = _totalPrice.asStateFlow() val totalPrice: StateFlow<String> = _totalPrice.asStateFlow()
// 支付二维码URL // 支付二维码URL
private val _payQrCodeUrl = MutableStateFlow<String?>(null) private val _payQrCodeUrl = MutableStateFlow<String?>(null)
val payQrCodeUrl: StateFlow<String?> = _payQrCodeUrl.asStateFlow() val payQrCodeUrl: StateFlow<String?> = _payQrCodeUrl.asStateFlow()
// 支付弹框关闭事件(支付完成、取消或退款时由 ViewModel 驱动关闭)
private val _dismissPayDialogEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val dismissPayDialogEvents: SharedFlow<Unit> = _dismissPayDialogEvents.asSharedFlow()
private var activePaymentOrderNumber: String? = null
private var paymentPollingJob: Job? = null
private val paymentHandled = AtomicBoolean(false)
// 图片加载状态:URL -> (重试次数, 是否加载失败) // 图片加载状态:URL -> (重试次数, 是否加载失败)
private val _imageLoadStates = MutableStateFlow<Map<String, ImageLoadState>>(emptyMap()) private val _imageLoadStates = MutableStateFlow<Map<String, ImageLoadState>>(emptyMap())
val imageLoadStates: StateFlow<Map<String, ImageLoadState>> = _imageLoadStates.asStateFlow() val imageLoadStates: StateFlow<Map<String, ImageLoadState>> = _imageLoadStates.asStateFlow()
companion object { companion object {
private const val TAG = "FaceRecognitionResultViewModel"
private const val MAX_RETRY_COUNT = 3 private const val MAX_RETRY_COUNT = 3
private const val PAY_STATUS_POLL_INTERVAL_MS = 2_000L
} }
data class ImageLoadState( data class ImageLoadState(
val retryCount: Int = 0, val retryCount: Int = 0,
val isFailed: Boolean = false, val isFailed: Boolean = false,
val isLoading: Boolean = false val isLoading: Boolean = false
) )
init { init {
// 监听 WebSocket 支付成功事件 // 监听 WebSocket 支付成功事件
webSocketService.uploadPhotoEvents webSocketService.uploadPhotoEvents
.onEach { event -> .onEach { event ->
when (event) { when (event) {
is UploadPhotoEvent.PaySuccess -> { is UploadPhotoEvent.PaySuccess -> {
// 支付成功,跳转到支付成功页面 LogUtils.d(TAG, "收到 WebSocket 支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, image_ids: ${event.imageIds}")
LogUtils.d("FaceRecognitionResultViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, image_ids: ${event.imageIds}") handlePaymentSuccess(
navigateToPaySuccess(event.orderNumber, event.captureType, event.imageIds) orderNumber = event.orderNumber,
captureType = event.captureType,
imageIds = event.imageIds,
source = "WebSocket"
)
} }
else -> { else -> {
// 其他事件不处理 // 其他事件不处理
@@ -104,7 +120,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
} }
.launchIn(viewModelScope) .launchIn(viewModelScope)
} }
/** /**
* 初始化图片列表(从人脸识别结果) * 初始化图片列表(从人脸识别结果)
*/ */
@@ -112,7 +128,8 @@ class FaceRecognitionResultViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
// 保存原始结果列表 // 保存原始结果列表
originalResults = results originalResults = results
urlToIdMap.clear()
// 先使用默认宽高比创建列表 // 先使用默认宽高比创建列表
val initialPhotos = results.mapNotNull { result -> val initialPhotos = results.mapNotNull { result ->
// 使用 thumbnail_oss_url // 使用 thumbnail_oss_url
@@ -131,87 +148,52 @@ class FaceRecognitionResultViewModel @Inject constructor(
) )
} }
} }
if (initialPhotos.isEmpty()) { if (initialPhotos.isEmpty()) {
LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL") LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL")
_photoList.value = emptyList() _photoList.value = emptyList()
_imageLoadStates.value = emptyMap() _imageLoadStates.value = emptyMap()
return@launch return@launch
} }
_photoList.value = initialPhotos _photoList.value = initialPhotos
// 初始化加载状态 // 初始化加载状态
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() } _imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
// 页面初始化时调用验证接口(空列表,type: 2),获取默认价格 // 页面初始化时调用验证接口(空列表,type: 2),获取默认价格
verifyResult(emptyList()) verifyResult(emptyList())
LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新")
// 并发获取所有图片的真实尺寸并更新宽高比
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} 张")
} }
} }
/** /**
* 获取图片的宽高比 * 使用网格中已经加载成功的 Drawable 尺寸更新宽高比。
* 这样不会为了获取尺寸额外下载、解码一次图片。
*/ */
private suspend fun getImageAspectRatio(imageUrl: String): Float { fun updatePhotoAspectRatio(photoId: Int, width: Int, height: Int) {
return withTimeoutOrNull(10000) { // 10秒超时 if (width <= 0 || height <= 0) {
try { LogUtils.i(TAG, "忽略无效图片尺寸 - id: $photoId, size: ${width}x${height}")
val imageLoader = ImageLoader(App.instance) return
val request = ImageRequest.Builder(App.instance) }
.data(imageUrl)
.size(Size.ORIGINAL) val aspectRatio = width.toFloat() / height.toFloat()
.allowHardware(false) // 禁用硬件加速,避免某些图片无法获取尺寸 if (!aspectRatio.isFinite() || aspectRatio !in 0.05f..20f) {
.build() LogUtils.i(TAG, "忽略异常图片宽高比 - id: $photoId, ratio: $aspectRatio")
return
val result = imageLoader.execute(request) }
val drawable = result.drawable
_photoList.update { photos ->
if (drawable != null) { photos.map { photo ->
val width = drawable.intrinsicWidth if (photo.id == photoId && kotlin.math.abs(photo.aspectRatio - aspectRatio) > 0.01f) {
val height = drawable.intrinsicHeight LogUtils.d(TAG, "更新图片比例 - id: $photoId, size: ${width}x${height}, ratio: $aspectRatio")
photo.copy(aspectRatio = aspectRatio)
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}")
}
} else { } else {
LogUtils.i("FaceRecognitionResultViewModel", "图片 $imageUrl 加载失败,drawable为null") photo
} }
} catch (e: Exception) {
LogUtils.e("FaceRecognitionResultViewModel"+"获取图片 $imageUrl 尺寸异常: ${e.message}")
} }
null
} ?: run {
LogUtils.i("FaceRecognitionResultViewModel", "获取图片 $imageUrl 尺寸超时")
1f // 超时或失败,返回默认值
} }
} }
/** /**
* 切换单张图片选中状态 * 切换单张图片选中状态
*/ */
@@ -223,12 +205,12 @@ class FaceRecognitionResultViewModel @Inject constructor(
current.add(url) current.add(url)
} }
_selectedPhotos.value = current _selectedPhotos.value = current
// 选择变化时调用接口获取价格 // 选择变化时调用接口获取价格
val selectedIds = getSelectedImageIds() val selectedIds = getSelectedImageIds()
verifyResult(selectedIds) verifyResult(selectedIds)
} }
/** /**
* 切换全选状态 * 切换全选状态
*/ */
@@ -242,12 +224,12 @@ class FaceRecognitionResultViewModel @Inject constructor(
allUrls allUrls
} }
_selectedPhotos.value = newSelected _selectedPhotos.value = newSelected
// 选择变化时调用接口获取价格 // 选择变化时调用接口获取价格
val selectedIds = getSelectedImageIds() val selectedIds = getSelectedImageIds()
verifyResult(selectedIds) verifyResult(selectedIds)
} }
/** /**
* 获取已选中的图片ID列表 * 获取已选中的图片ID列表
*/ */
@@ -256,7 +238,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
urlToIdMap[url] urlToIdMap[url]
} }
} }
/** /**
* 验证结果接口 * 验证结果接口
*/ */
@@ -267,7 +249,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
type = 2, type = 2,
imageId = imageIds imageId = imageIds
) )
handleResultWithData( handleResultWithData(
flow = netWorkRepository.verifyResult(request).asResult(), flow = netWorkRepository.verifyResult(request).asResult(),
showToast = false, showToast = false,
@@ -279,7 +261,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
} else { } else {
_pricePerPhoto.value = "--" _pricePerPhoto.value = "--"
} }
// 更新总价(使用接口返回的 amount) // 更新总价(使用接口返回的 amount)
val amount = response.amount val amount = response.amount
if (!amount.isNullOrEmpty()) { if (!amount.isNullOrEmpty()) {
@@ -287,7 +269,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
} else { } else {
_totalPrice.value = "--" _totalPrice.value = "--"
} }
LogUtils.d("FaceRecognitionResultViewModel", "验证结果成功 - 单价: $priceImage, 总价: $amount") LogUtils.d("FaceRecognitionResultViewModel", "验证结果成功 - 单价: $priceImage, 总价: $amount")
}, },
onError = { msg, _ -> onError = { msg, _ ->
@@ -308,7 +290,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
} }
} }
} }
/** /**
* 获取支付URL * 获取支付URL
*/ */
@@ -325,15 +307,21 @@ class FaceRecognitionResultViewModel @Inject constructor(
type = 2, type = 2,
imageId = selectedIds imageId = selectedIds
) )
handleResultWithData( handleResultWithData(
flow = netWorkRepository.getPayUrl(request).asResult(), flow = netWorkRepository.getPayUrl(request).asResult(),
showToast = false, showToast = false,
onData = { response -> onData = { response ->
val url = response.url val url = response.url?.trim()
if (url.isNullOrEmpty()) { val orderNumber = response.orderNumber?.trim()
if (url.isNullOrEmpty() || orderNumber.isNullOrEmpty()) {
_payQrCodeUrl.value = null
LogUtils.e(TAG, "获取支付二维码成功,但 url 或 order_number 为空")
ToastUtils.show("获取支付二维码失败") ToastUtils.show("获取支付二维码失败")
} else { } else {
stopPayStatusPolling()
activePaymentOrderNumber = orderNumber
paymentHandled.set(false)
_payQrCodeUrl.value = url _payQrCodeUrl.value = url
onSuccess(url) onSuccess(url)
} }
@@ -352,24 +340,176 @@ class FaceRecognitionResultViewModel @Inject constructor(
} }
} }
} }
/** /**
* 跳转到支付成功页面 * 二维码弹框显示时启动支付状态轮询。重复调用不会创建多个轮询任务。
*/
fun startPayStatusPolling() {
val orderNumber = activePaymentOrderNumber
if (orderNumber.isNullOrEmpty() || paymentHandled.get()) {
LogUtils.i(TAG, "未启动支付状态轮询:当前没有有效待支付订单")
return
}
if (paymentPollingJob?.isActive == true) {
return
}
paymentPollingJob = viewModelScope.launch {
LogUtils.d(TAG, "开始轮询支付状态 - order_number: $orderNumber")
while (
isActive &&
!paymentHandled.get() &&
activePaymentOrderNumber == orderNumber
) {
queryPayStatus(orderNumber)
delay(PAY_STATUS_POLL_INTERVAL_MS)
}
}
}
/** 二维码弹框关闭时停止 HTTP 轮询,WebSocket 监听仍保持有效。 */
fun stopPayStatusPolling() {
paymentPollingJob?.cancel()
paymentPollingJob = null
}
fun onPayDialogDismissed() {
stopPayStatusPolling()
}
private suspend fun queryPayStatus(orderNumber: String) {
try {
val response = netWorkRepository
.getPaySuccessMessage(PaySuccessMessageRequest(orderNumber))
.first()
if (!response.isSucceeded) {
LogUtils.e(
TAG,
"查询支付状态业务失败 - order_number: $orderNumber, code: ${response.code}, msg: ${response.message}"
)
return
}
val message = response.data
when (decideFacePayStatus(message?.orderStatus)) {
FacePayStatusDecision.CONTINUE_POLLING -> {
LogUtils.d(
TAG,
"订单尚未完成,继续轮询 - order_number: $orderNumber, status: ${message?.orderStatus}, name: ${message?.orderStatusName}"
)
}
FacePayStatusDecision.COMPLETE_PAYMENT -> {
if (!isValidFacePaySuccessMessage(orderNumber, message)) {
LogUtils.e(TAG, "支付完成消息校验失败,等待下一次查询或 WebSocket - order_number: $orderNumber, message: $message")
return
}
val paymentData = checkNotNull(message?.data)
handlePaymentSuccess(
orderNumber = paymentData.orderNumber,
captureType = paymentData.captureType,
imageIds = paymentData.imageIds.orEmpty(),
source = "HTTP"
)
}
FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS -> {
handleTerminalPayStatus(message?.orderStatus, message?.orderStatusName)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LogUtils.e(TAG, "查询支付状态异常 - order_number: $orderNumber, error: ${e.message}")
}
}
private fun handleTerminalPayStatus(orderStatus: Int?, orderStatusName: String?) {
if (!paymentHandled.compareAndSet(false, true)) {
return
}
stopPayStatusPolling()
activePaymentOrderNumber = null
_payQrCodeUrl.value = null
_dismissPayDialogEvents.tryEmit(Unit)
val statusText = orderStatusName?.takeIf { it.isNotBlank() } ?: when (orderStatus) {
40 -> "已取消"
50 -> "已退款"
60 -> "部分退款"
else -> "状态异常"
}
ToastUtils.show("订单$statusText")
LogUtils.i(TAG, "订单进入终态,停止支付轮询 - status: $orderStatus, name: $statusText")
}
private fun handlePaymentSuccess(
orderNumber: String?,
captureType: Int?,
imageIds: List<Int>,
source: String
) {
val expectedOrderNumber = activePaymentOrderNumber
if (
expectedOrderNumber.isNullOrEmpty() ||
orderNumber != expectedOrderNumber ||
captureType != 2 ||
imageIds.isEmpty()
) {
LogUtils.i(
TAG,
"忽略不匹配的支付成功消息 - source: $source, expected: $expectedOrderNumber, actual: $orderNumber, capture_type: $captureType, image_ids: $imageIds"
)
return
}
val knownImageIds = originalResults.map { it.id }.toSet()
if (!knownImageIds.containsAll(imageIds)) {
LogUtils.e(TAG, "支付成功消息包含未知图片,暂不跳转 - source: $source, image_ids: $imageIds")
return
}
if (!paymentHandled.compareAndSet(false, true)) {
LogUtils.d(TAG, "支付成功已处理,忽略重复消息 - source: $source, order_number: $orderNumber")
return
}
LogUtils.d(TAG, "确认支付成功 - source: $source, order_number: $orderNumber")
stopPayStatusPolling()
_payQrCodeUrl.value = null
_dismissPayDialogEvents.tryEmit(Unit)
navigateToPrinting(orderNumber, captureType, imageIds)
}
/**
* 支付成功后直接跳转到打印页面
* @param orderNumber 订单号 * @param orderNumber 订单号
* @param captureType 抓拍类型 * @param captureType 抓拍类型
* @param imageIds 图片ID数组(从支付成功消息中获取) * @param imageIds 图片ID数组(从支付成功消息中获取)
*/ */
private fun navigateToPaySuccess(orderNumber: String?, captureType: Int?, imageIds: List<Int>) { private fun navigateToPrinting(orderNumber: String?, captureType: Int?, imageIds: List<Int>) {
viewModelScope.launch { viewModelScope.launch {
if (orderNumber.isNullOrBlank() || captureType == null) {
LogUtils.e(
TAG,
"支付成功消息订单信息不完整 - orderNumber: $orderNumber, captureType: $captureType"
)
ToastUtils.show("支付成功,但订单信息不完整")
return@launch
}
// 根据 imageIds 从 originalResults 中获取对应的 FaceSearchResult // 根据 imageIds 从 originalResults 中获取对应的 FaceSearchResult
val selectedResults = originalResults.filter { imageIds.contains(it.id) } val selectedResults = originalResults.filter { imageIds.contains(it.id) }
if (selectedResults.isEmpty()) { if (selectedResults.isEmpty()) {
LogUtils.e("FaceRecognitionResultViewModel", "未找到对应的图片数据,imageIds: $imageIds") LogUtils.e("FaceRecognitionResultViewModel", "未找到对应的图片数据,imageIds: $imageIds")
ToastUtils.show("支付成功,但未找到对应的图片数据") ToastUtils.show("支付成功,但未找到对应的图片数据")
return@launch return@launch
} }
// 将 FaceSearchResult 转换为 FileMapData // 将 FaceSearchResult 转换为 FileMapData
val fileMapDataList = selectedResults.map { result -> val fileMapDataList = selectedResults.map { result ->
FileMapData( FileMapData(
@@ -380,27 +520,21 @@ class FaceRecognitionResultViewModel @Inject constructor(
originalLanUrl = result.originalLanUrl originalLanUrl = result.originalLanUrl
) )
} }
// 将 FileMapData 数组序列化为 JSON 数组字符串 // 将 FileMapData 数组序列化为 JSON 数组字符串
val gson = Gson() val gson = Gson()
val jsonArray = gson.toJson(fileMapDataList) val jsonArray = gson.toJson(fileMapDataList)
val encodedJson = java.net.URLEncoder.encode(jsonArray, "UTF-8")
// 构建路由参数
val orderNumberParam = orderNumber?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: ""
val captureTypeParam = captureType?.toString() ?: ""
// 创建 NavOptions,清除当前页面 // 创建 NavOptions,清除当前页面
val navOptions = androidx.navigation.NavOptions.Builder() val navOptions = androidx.navigation.NavOptions.Builder()
.setPopUpTo(com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true) .setPopUpTo(AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true)
.build() .build()
// 跳转到支付成功页面 val route = AppRoutes.buildPrintingRoute(jsonArray, orderNumber, captureType)
val route = "${com.yzx.kiosk.navigation.routes.AppRoutes.PAY_SUCCESS}?urls=$encodedJson&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
toPage(route, navOptions) toPage(route, navOptions)
} }
} }
/** /**
* 处理图片加载失败 * 处理图片加载失败
*/ */
@@ -408,12 +542,12 @@ class FaceRecognitionResultViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
val currentStates = _imageLoadStates.value.toMutableMap() val currentStates = _imageLoadStates.value.toMutableMap()
val currentState = currentStates[url] ?: ImageLoadState() val currentState = currentStates[url] ?: ImageLoadState()
// 如果已经在加载中,避免重复处理 // 如果已经在加载中,避免重复处理
if (currentState.isLoading) { if (currentState.isLoading) {
return@launch return@launch
} }
if (currentState.retryCount < MAX_RETRY_COUNT) { if (currentState.retryCount < MAX_RETRY_COUNT) {
// 重试 // 重试
val newRetryCount = currentState.retryCount + 1 val newRetryCount = currentState.retryCount + 1
@@ -423,10 +557,10 @@ class FaceRecognitionResultViewModel @Inject constructor(
isLoading = true isLoading = true
) )
_imageLoadStates.value = currentStates _imageLoadStates.value = currentStates
// 延迟后重试(给 Coil 一些时间) // 延迟后重试(给 Coil 一些时间)
kotlinx.coroutines.delay(500) kotlinx.coroutines.delay(500)
// 触发重新加载(通过更新 URL 来强制 Coil 重新加载) // 触发重新加载(通过更新 URL 来强制 Coil 重新加载)
val updatedList = _photoList.value.map { photo -> val updatedList = _photoList.value.map { photo ->
if (photo.url == url || photo.url.startsWith("$url?")) { if (photo.url == url || photo.url.startsWith("$url?")) {
@@ -437,7 +571,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
} }
} }
_photoList.value = updatedList _photoList.value = updatedList
// 恢复原始 URL(避免 URL 被污染) // 恢复原始 URL(避免 URL 被污染)
kotlinx.coroutines.delay(200) kotlinx.coroutines.delay(200)
val restoredList = _photoList.value.map { photo -> val restoredList = _photoList.value.map { photo ->
@@ -460,7 +594,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
} }
} }
} }
/** /**
* 处理图片加载成功 * 处理图片加载成功
*/ */
@@ -476,7 +610,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
} }
} }
} }
/** /**
* 手动重试加载图片 * 手动重试加载图片
*/ */
@@ -486,9 +620,9 @@ class FaceRecognitionResultViewModel @Inject constructor(
// 重置状态,重新开始重试 // 重置状态,重新开始重试
currentStates[url] = ImageLoadState(isLoading = true, retryCount = 0) currentStates[url] = ImageLoadState(isLoading = true, retryCount = 0)
_imageLoadStates.value = currentStates _imageLoadStates.value = currentStates
LogUtils.d("FaceRecognitionResultViewModel", "手动重试加载图片: $url") LogUtils.d("FaceRecognitionResultViewModel", "手动重试加载图片: $url")
// 触发重新加载 // 触发重新加载
val updatedList = _photoList.value.map { photo -> val updatedList = _photoList.value.map { photo ->
if (photo.url == url || photo.url.startsWith("$url?")) { if (photo.url == url || photo.url.startsWith("$url?")) {
@@ -498,7 +632,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
} }
} }
_photoList.value = updatedList _photoList.value = updatedList
// 恢复原始 URL // 恢复原始 URL
kotlinx.coroutines.delay(200) kotlinx.coroutines.delay(200)
val restoredList = _photoList.value.map { photo -> val restoredList = _photoList.value.map { photo ->
@@ -1,10 +1,7 @@
package com.yzx.kiosk.ui.face.viewmodel package com.yzx.kiosk.ui.face.viewmodel
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
import androidx.exifinterface.media.ExifInterface
import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageCaptureException
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -16,16 +13,20 @@ import com.yzx.kiosk.datastore.AppStoreDataSource
import com.google.gson.Gson import com.google.gson.Gson
import com.yzx.kiosk.network.model.response.FaceSearchResponse import com.yzx.kiosk.network.model.response.FaceSearchResponse
import com.yzx.kiosk.network.service.FaceSearchService import com.yzx.kiosk.network.service.FaceSearchService
import com.yzx.kiosk.network.service.buildFaceSearchUrl
import com.yzx.kiosk.App import com.yzx.kiosk.App
import com.yzx.kiosk.BuildConfig import com.yzx.kiosk.BuildConfig
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
import com.yzx.kiosk.navigation.AppNavigator import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes
import com.yzx.kiosk.ui.face.resource.CaptureGate
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
import com.yzx.kiosk.ui.face.resource.FaceThumbnailDecoder
import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.LogUtils
import com.yzx.kiosk.utils.ToastUtils import com.yzx.kiosk.utils.ToastUtils
import java.net.URLEncoder import java.net.URLEncoder
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -35,12 +36,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -58,7 +55,10 @@ class FaceRecognitionViewModel @Inject constructor(
navigator: AppNavigator, navigator: AppNavigator,
appState: AppState, appState: AppState,
private val appStoreDataSource: AppStoreDataSource, private val appStoreDataSource: AppStoreDataSource,
private val localAudioPlayService: LocalAudioPlayService private val localAudioPlayService: LocalAudioPlayService,
private val faceSearchService: FaceSearchService,
private val faceCaptureFileStore: FaceCaptureFileStore,
private val faceThumbnailDecoder: FaceThumbnailDecoder,
) : BaseViewModel( ) : BaseViewModel(
navigator = navigator, navigator = navigator,
appState = appState appState = appState
@@ -89,10 +89,17 @@ class FaceRecognitionViewModel @Inject constructor(
// 是否正在识别 // 是否正在识别
private val _isRecognizing = MutableStateFlow(false) private val _isRecognizing = MutableStateFlow(false)
val isRecognizing: StateFlow<Boolean> = _isRecognizing.asStateFlow() val isRecognizing: StateFlow<Boolean> = _isRecognizing.asStateFlow()
private val _isCaptureInProgress = MutableStateFlow(false)
val isCaptureInProgress: StateFlow<Boolean> = _isCaptureInProgress.asStateFlow()
// 倒计时Job // 倒计时Job
private var countdownJob: kotlinx.coroutines.Job? = null private var countdownJob: kotlinx.coroutines.Job? = null
private var autoCaptureJob: kotlinx.coroutines.Job? = null private var autoCaptureJob: kotlinx.coroutines.Job? = null
private val captureGate = CaptureGate()
private var activeCaptureToken: Long? = null
private var activeCaptureFile: File? = null
private var isCleared = false
// ImageCapture实例 // ImageCapture实例
private var imageCapture: ImageCapture? = null private var imageCapture: ImageCapture? = null
@@ -107,6 +114,15 @@ class FaceRecognitionViewModel @Inject constructor(
fun setImageCapture(imageCapture: ImageCapture) { fun setImageCapture(imageCapture: ImageCapture) {
this.imageCapture = imageCapture this.imageCapture = imageCapture
} }
fun clearImageCapture(imageCapture: ImageCapture) {
if (this.imageCapture === imageCapture) {
this.imageCapture = null
activeCaptureToken?.let { captureToken ->
finishCaptureFile(activeCaptureFile, captureToken)
}
}
}
/** /**
* 开始倒计时 * 开始倒计时
@@ -119,7 +135,7 @@ class FaceRecognitionViewModel @Inject constructor(
_countdown.value = _countdown.value - 1 _countdown.value = _countdown.value - 1
} }
// 倒计时结束,自动返回首页 // 倒计时结束,自动返回首页
toPage(com.yzx.kiosk.navigation.routes.AppRoutes.HOME) closeAllExcept(AppRoutes.HOME)
} }
} }
@@ -153,6 +169,12 @@ class FaceRecognitionViewModel @Inject constructor(
* 手动拍照 * 手动拍照
*/ */
fun takePhoto() { fun takePhoto() {
if (_isRecognizing.value) return
val captureToken = captureGate.tryAcquire() ?: return
activeCaptureToken = captureToken
_isCaptureInProgress.value = true
// 如果正在自动拍照倒计时,先取消 // 如果正在自动拍照倒计时,先取消
if (_captureCountdown.value > 0) { if (_captureCountdown.value > 0) {
cancelAutoCapture() cancelAutoCapture()
@@ -166,68 +188,107 @@ class FaceRecognitionViewModel @Inject constructor(
} }
val capture = imageCapture ?: run { val capture = imageCapture ?: run {
releaseCaptureGate(captureToken)
ToastUtils.show("相机未准备好") ToastUtils.show("相机未准备好")
return return
} }
viewModelScope.launch { viewModelScope.launch {
// 创建输出文件 // 创建输出文件
val photoFile = withContext(Dispatchers.IO) { var photoFile: File? = null
File.createTempFile( try {
"IMG_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())}", photoFile = withContext(Dispatchers.IO) {
".jpg", faceCaptureFileStore.createCaptureFile()
App.instance.cacheDir
)
}
// 创建输出选项
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
// 拍照
capture.takePicture(
outputOptions,
ContextCompat.getMainExecutor(App.instance),
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
// 加载图片为Bitmap,并处理EXIF方向
val bitmap = loadBitmapWithExifOrientation(photoFile.absolutePath)
_capturedImage.value = bitmap
// 直接调用搜索接口
searchFaceDirectly(photoFile)
}
override fun onError(exception: ImageCaptureException) {
LogUtils.e(TAG, "拍照失败: ${exception.message}")
ToastUtils.show("拍照失败")
}
} }
) if (
isCleared ||
imageCapture !== capture ||
activeCaptureToken != captureToken
) {
faceCaptureFileStore.delete(photoFile)
releaseCaptureGate(captureToken)
return@launch
}
activeCaptureFile = photoFile
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
capture.takePicture(
outputOptions,
ContextCompat.getMainExecutor(App.instance),
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
if (
isCleared ||
imageCapture !== capture ||
activeCaptureToken != captureToken
) {
finishCaptureFile(photoFile, captureToken)
return
}
recognizeSavedPhoto(photoFile, captureToken)
}
override fun onError(exception: ImageCaptureException) {
LogUtils.e(TAG, "拍照失败: ${exception.message}")
finishCaptureFile(photoFile, captureToken)
if (!isCleared && imageCapture === capture) {
ToastUtils.show("拍照失败")
}
}
},
)
} catch (e: CancellationException) {
finishCaptureFile(photoFile, captureToken)
throw e
} catch (e: Exception) {
LogUtils.e(TAG, "创建人脸拍照文件失败: ${e.message}")
finishCaptureFile(photoFile, captureToken)
ToastUtils.show("拍照失败")
}
} }
} }
/** private fun recognizeSavedPhoto(photoFile: File, captureToken: Long) {
* 直接调用人脸搜索接口
*/
private fun searchFaceDirectly(photoFile: File) {
viewModelScope.launch { viewModelScope.launch {
_isRecognizing.value = true _isRecognizing.value = true
_recognitionStatus.value = RecognitionStatus.RECOGNIZING _recognitionStatus.value = RecognitionStatus.RECOGNIZING
// 播放识别中音频 releaseCaptureGate(captureToken)
localAudioPlayService.playByRoute("face_recognition_recognizing") localAudioPlayService.playByRoute("face_recognition_recognizing")
// 播放识别中音频
localAudioPlayService.playByRoute("face_recognition_recognizing")
try { try {
// 直接调用人脸识别接口 _capturedImage.value = withContext(Dispatchers.IO) {
faceThumbnailDecoder.decode(photoFile.absolutePath)
}
searchFace(photoFile) searchFace(photoFile)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
LogUtils.e(TAG, "处理失败: ${e.message}") LogUtils.e(TAG, "处理失败: ${e.message}")
ToastUtils.show("处理失败: ${e.message}") ToastUtils.show("处理失败: ${e.message}")
_isRecognizing.value = false _isRecognizing.value = false
_recognitionStatus.value = RecognitionStatus.FAILED _recognitionStatus.value = RecognitionStatus.FAILED
} finally {
finishCaptureFile(photoFile, captureToken)
} }
} }
} }
private fun releaseCaptureGate(captureToken: Long) {
if (captureGate.release(captureToken)) {
if (activeCaptureToken == captureToken) {
activeCaptureToken = null
}
_isCaptureInProgress.value = false
}
}
private fun finishCaptureFile(photoFile: File?, captureToken: Long) {
faceCaptureFileStore.delete(photoFile)
if (activeCaptureFile === photoFile) {
activeCaptureFile = null
}
releaseCaptureGate(captureToken)
}
/** /**
* 搜索人脸 * 搜索人脸
@@ -235,8 +296,16 @@ class FaceRecognitionViewModel @Inject constructor(
private suspend fun searchFace(imageFile: File) = withContext(Dispatchers.IO) { private suspend fun searchFace(imageFile: File) = withContext(Dispatchers.IO) {
try { try {
val FACE_SEARCH_BASE_URL = if (appStoreDataSource.getUseLan()) appStoreDataSource.getBindBoxLanUrl() else appStoreDataSource.getBindBoxUrl() val faceSearchBaseUrl = if (appStoreDataSource.getUseLan()) {
val FACE_SEARCH_TOKEN = appStoreDataSource.getBindBoxApiToken() appStoreDataSource.getBindBoxLanUrl()
} else {
appStoreDataSource.getBindBoxUrl()
}
val faceSearchToken = appStoreDataSource.getBindBoxApiToken()
val requestUrl = buildFaceSearchUrl(
baseUrl = faceSearchBaseUrl,
deviceSn = appStoreDataSource.getBindBoxSn(),
)
// 创建MultipartBody // 创建MultipartBody
@@ -250,13 +319,10 @@ class FaceRecognitionViewModel @Inject constructor(
// index_date 参数:当前日期,格式 YYYYMMDD // index_date 参数:当前日期,格式 YYYYMMDD
val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault()) val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault())
val indexDate = dateFormat.format(Date()) val indexDate = dateFormat.format(Date())
val indexDateBody = indexDate.toRequestBody("text/plain".toMediaType())
// Authorization header // Authorization header
val authorization = "Bearer $FACE_SEARCH_TOKEN" val authorization = "Bearer $faceSearchToken"
// 打印请求信息 // 打印请求信息
val requestUrl = "$FACE_SEARCH_BASE_URL/api/search"
LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========") LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========")
LogUtils.d(TAG, "URL: $requestUrl") LogUtils.d(TAG, "URL: $requestUrl")
LogUtils.d(TAG, "Headers:") LogUtils.d(TAG, "Headers:")
@@ -267,27 +333,9 @@ class FaceRecognitionViewModel @Inject constructor(
LogUtils.d(TAG, "index_date: $indexDate") LogUtils.d(TAG, "index_date: $indexDate")
LogUtils.d(TAG, "==========================================") LogUtils.d(TAG, "==========================================")
// Debug 环境记录请求与响应;图片等大文件只记录元数据,不读取文件内容
val client = OkHttpClient.Builder()
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
}
}
.build()
// 创建Retrofit实例
val retrofit = Retrofit.Builder()
.baseUrl("$FACE_SEARCH_BASE_URL/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
val service = retrofit.create(FaceSearchService::class.java)
// 调用接口 // 调用接口
val response = service.searchFace( val response = faceSearchService.searchFace(
sn = appStoreDataSource.getBindBoxSn(), url = requestUrl,
authorization = authorization, authorization = authorization,
image = imagePart, image = imagePart,
threshold = thresholdBody, threshold = thresholdBody,
@@ -315,6 +363,9 @@ class FaceRecognitionViewModel @Inject constructor(
localAudioPlayService.playByRoute("face_recognition_failed") localAudioPlayService.playByRoute("face_recognition_failed")
} }
} }
} catch (e: CancellationException) {
// 页面离开时保持协程取消语义,避免误显示 "Job was cancelled"
throw e
} catch (e: Exception) { } catch (e: Exception) {
LogUtils.e("人脸识别失败: ${e.message}") LogUtils.e("人脸识别失败: ${e.message}")
ToastUtils.show("识别失败: ${e.message}") ToastUtils.show("识别失败: ${e.message}")
@@ -342,7 +393,7 @@ class FaceRecognitionViewModel @Inject constructor(
} else { } else {
// 识别成功 // 识别成功
_recognitionStatus.value = RecognitionStatus.SUCCESS _recognitionStatus.value = RecognitionStatus.SUCCESS
ToastUtils.show("找到 ${result.count} 张照片") LogUtils.i(TAG, "找到 ${result.count} 张照片")
// 播放识别成功音频(在跳转前播放) // 播放识别成功音频(在跳转前播放)
localAudioPlayService.playByRoute(com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION_RESULT) localAudioPlayService.playByRoute(com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION_RESULT)
@@ -362,84 +413,6 @@ 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)
}
}
fun privatePolicy() { fun privatePolicy() {
val args = mapOf( val args = mapOf(
AgreementRoutes.AGREEMENT_URL to BuildConfig.BASE_URL + "/pages/oscar/private-policy.html", AgreementRoutes.AGREEMENT_URL to BuildConfig.BASE_URL + "/pages/oscar/private-policy.html",
@@ -469,9 +442,15 @@ class FaceRecognitionViewModel @Inject constructor(
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() isCleared = true
countdownJob?.cancel() countdownJob?.cancel()
autoCaptureJob?.cancel() autoCaptureJob?.cancel()
imageCapture = null
_capturedImage.value = null
faceCaptureFileStore.delete(activeCaptureFile)
activeCaptureFile = null
activeCaptureToken?.let(::releaseCaptureGate)
super.onCleared()
} }
} }
@@ -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)
)
}
}
}
}
@@ -275,16 +275,10 @@ fun PhotoSelectScreen(
// 支付弹框 // 支付弹框
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) { if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) {
val selectedUrls = photoList.filter { selectedPhotoIds.contains(it.id) }.map { it.url }
WechatPayDialog( WechatPayDialog(
qrCodeUrl = payQrCodeUrl!!, qrCodeUrl = payQrCodeUrl!!,
totalPrice = if (totalPrice != "--") totalPrice else "0", totalPrice = if (totalPrice != "--") totalPrice else "0",
onDismiss = { showPayDialog = false }, onDismiss = { showPayDialog = false }
onPaySuccess = {
showPayDialog = false
// 支付成功,等待 WebSocket 消息(code=5)触发跳转到支付成功页面
// 跳转逻辑在 PhotoSelectViewModel 的 navigateToPaySuccess 方法中处理
}
) )
} }
} }
@@ -453,8 +447,7 @@ fun PhotoItem(
fun WechatPayDialog( fun WechatPayDialog(
qrCodeUrl: String, qrCodeUrl: String,
totalPrice: String, totalPrice: String,
onDismiss: () -> Unit, onDismiss: () -> Unit
onPaySuccess: () -> Unit
) { ) {
// 使用接口返回的URL生成支付二维码 // 使用接口返回的URL生成支付二维码
val qrCodeBitmap = remember(qrCodeUrl) { val qrCodeBitmap = remember(qrCodeUrl) {
@@ -1,273 +0,0 @@
package com.yzx.kiosk.ui.upload.viewmodel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavOptions
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Size
import com.yzx.kiosk.App
import com.yzx.kiosk.base.BaseViewModel
import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.yzx.kiosk.datastore.AppStoreDataSource
import com.yzx.kiosk.utils.LogUtils
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
class PaySuccessViewModel @Inject constructor(
navigator: AppNavigator,
private val appStoreDataSource: AppStoreDataSource,
appState: AppState
) : BaseViewModel(
navigator = navigator,
appState = appState
) {
// 图片列表(包含宽高比)
private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList())
val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow()
// 已选中的图片ID
private val _selectedPhotoIds = MutableStateFlow<Set<Int>>(emptySet())
val selectedPhotoIds: StateFlow<Set<Int>> = _selectedPhotoIds.asStateFlow()
// 订单号
private val _orderNumber = MutableStateFlow<String>("")
val orderNumber: StateFlow<String> = _orderNumber.asStateFlow()
// 抓拍类型
private val _captureType = MutableStateFlow<Int?>(null)
val captureType: StateFlow<Int?> = _captureType.asStateFlow()
/** 设备配置下发的打印按钮图 URL(可能为空) */
private val _buttonPrintUrl = MutableStateFlow("")
val buttonPrintUrl: StateFlow<String> = _buttonPrintUrl.asStateFlow()
init {
_buttonPrintUrl.value = appStoreDataSource.getButtonPrintUrl()
}
/**
* 初始化图片列表,默认全选
* @param photoUrls 图片URL列表(JSON 格式或旧格式)
* @param orderNumber 订单号
* @param captureType 抓拍类型
* urls 格式:
* - JSON 格式(新):FileMapData 数组的 JSON 字符串
* - id:url 格式(兼容):id:url,id:url
* - url 格式(兼容):url,url
*/
fun initPhotoList(photoUrls: List<String>, orderNumber: String = "", captureType: Int? = null) {
// 保存订单信息
_orderNumber.value = orderNumber
_captureType.value = captureType
LogUtils.d("PaySuccessViewModel", "初始化 - orderNumber: $orderNumber, captureType: $captureType")
if (photoUrls.isEmpty()) {
_photoList.value = emptyList()
_selectedPhotoIds.value = emptySet()
return
}
viewModelScope.launch {
var initialPhotos: List<PhotoData>
// 尝试解析为 JSON 格式(新格式:file_map 数组的 JSON 字符串)
if (photoUrls.size == 1 && photoUrls[0].startsWith("[")) {
// JSON 数组格式:将 file_map 列表序列化后的 JSON 数组字符串反序列化为 FileMapData 对象数组
try {
val jsonString = java.net.URLDecoder.decode(photoUrls[0], "UTF-8")
LogUtils.d("PaySuccessViewModel", "接收到 JSON 数组字符串: $jsonString")
// 使用 Gson 将 JSON 数组反序列化为 FileMapData 对象数组
val type = object : TypeToken<List<FileMapData>>() {}.type
val fileMapList: List<FileMapData> = Gson().fromJson(jsonString, type) ?: emptyList()
LogUtils.d("PaySuccessViewModel", "反序列化成功,FileMapData 对象数量: ${fileMapList.size}")
// 将 FileMapData 对象数组转换为 PhotoData 列表
initialPhotos = fileMapList.mapNotNull { fileMap ->
val thumbnailUrl = if (appStoreDataSource.getUseLan()) fileMap.thumbnailLanUrl else fileMap.thumbnailOssUrl
if (thumbnailUrl.isNullOrEmpty()) {
LogUtils.i("PaySuccessViewModel", "FileMapData id=${fileMap.id} 的 thumbnailOssUrl 为空,跳过")
null
} else {
PhotoData(
id = fileMap.id,
url = thumbnailUrl.trim(),
aspectRatio = 1f,
fileMapData = fileMap // 保存完整的 FileMapData 对象,供后续使用
)
}
}
LogUtils.d("PaySuccessViewModel", "成功创建 PhotoData 列表,数量: ${initialPhotos.size}")
} catch (e: Exception) {
LogUtils.e("PaySuccessViewModel", "解析 JSON 格式失败: ${e.message}")
e.printStackTrace()
initialPhotos = emptyList()
}
} else {
// 兼容旧格式:id:url 或 url
initialPhotos = photoUrls.mapNotNull { urlStr ->
val trimmed = urlStr.trim()
if (trimmed.contains(":")) {
// id:url 格式
val parts = trimmed.split(":", limit = 2)
if (parts.size == 2) {
val id = parts[0].toIntOrNull()
val url = parts[1]
if (id != null && url.isNotEmpty()) {
PhotoData(
id = id,
url = url,
aspectRatio = 1f
)
} else null
} else null
} else {
// 只有 url,使用 hashCode 作为临时 id(不推荐,但兼容)
PhotoData(
id = trimmed.hashCode(),
url = trimmed,
aspectRatio = 1f
)
}
}
}
if (initialPhotos.isEmpty()) {
_photoList.value = emptyList()
_selectedPhotoIds.value = emptySet()
return@launch
}
_photoList.value = initialPhotos
// 默认全选(使用 id)
_selectedPhotoIds.value = initialPhotos.map { it.id }.toSet()
// 并发获取所有图片的真实尺寸并更新宽高比
val updatedPhotos = initialPhotos.mapIndexed { index, photo ->
async(Dispatchers.IO) {
try {
val aspectRatio = getImageAspectRatio(photo.url)
// 确保 aspectRatio 是有效值
val validAspectRatio = if (aspectRatio > 0 && aspectRatio.isFinite()) {
aspectRatio
} else {
1f
}
photo.copy(aspectRatio = validAspectRatio)
} catch (e: Exception) {
LogUtils.e("PaySuccessViewModel", "获取图片 ${photo.url} 尺寸失败: ${e.message}")
// 获取失败,保持默认值
photo
}
}
}.awaitAll()
_photoList.value = updatedPhotos
}
}
/**
* 获取图片的宽高比
*/
private suspend fun getImageAspectRatio(imageUrl: String): Float = withContext(Dispatchers.IO) {
try {
val imageLoader = ImageLoader(App.instance)
val request = ImageRequest.Builder(App.instance)
.data(imageUrl)
.size(Size.ORIGINAL)
.build()
val drawable = imageLoader.execute(request).drawable
if (drawable != null) {
val width = drawable.intrinsicWidth
val height = drawable.intrinsicHeight
if (width > 0 && height > 0) {
return@withContext width.toFloat() / height.toFloat()
}
}
} catch (e: Exception) {
// 获取失败,使用默认值
}
return@withContext 1f // 默认1:1
}
/**
* 切换单张图片选中状态
*/
fun togglePhotoSelection(id: Int) {
val current = _selectedPhotoIds.value.toMutableSet()
if (current.contains(id)) {
current.remove(id)
} else {
current.add(id)
}
_selectedPhotoIds.value = current
}
/**
* 切换全选状态
*/
fun toggleSelectAll() {
val allIds = _photoList.value.map { it.id }.toSet()
val newSelected = if (_selectedPhotoIds.value.size == allIds.size) {
// 当前全选,取消全选
emptySet()
} else {
// 全选
allIds
}
_selectedPhotoIds.value = newSelected
}
/**
* 点击打印
*/
fun onPrintClick() {
val selectedIds = _selectedPhotoIds.value.toList()
if (selectedIds.isEmpty()) return
// 获取选中的图片的完整 FileMapData 列表
val selectedFileMapData = _photoList.value
.filter { selectedIds.contains(it.id) }
.mapNotNull { it.fileMapData }
if (selectedFileMapData.isEmpty()) {
LogUtils.e("PaySuccessViewModel", "选中的图片没有 FileMapData,无法打印")
return
}
// 将 FileMapData 数组序列化为 JSON 数组字符串
val gson = Gson()
val jsonArray = gson.toJson(selectedFileMapData)
val encodedJson = java.net.URLEncoder.encode(jsonArray, "UTF-8")
// 构建路由参数
val orderNumberParam = _orderNumber.value.let {
if (it.isNotEmpty()) java.net.URLEncoder.encode(it, "UTF-8") else ""
}
val captureTypeParam = _captureType.value?.toString() ?: ""
// 创建 NavOptions,清除回退栈(清除到支付成功页面,包括它自己)
val navOptions = NavOptions.Builder()
.setPopUpTo(AppRoutes.PAY_SUCCESS, inclusive = true)
.build()
// 跳转到打印页面
val route = "${AppRoutes.PRINTING}?urls=$encodedJson&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
toPage(route, navOptions)
}
}
@@ -8,6 +8,7 @@ import com.yzx.kiosk.App
import com.yzx.kiosk.base.BaseViewModel import com.yzx.kiosk.base.BaseViewModel
import com.yzx.kiosk.datastore.AppState import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.navigation.AppNavigator import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.network.model.request.GetPayUrlRequest import com.yzx.kiosk.network.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.VerifyResultRequest import com.yzx.kiosk.network.model.request.VerifyResultRequest
import com.yzx.kiosk.network.repository.NetWorkRepository import com.yzx.kiosk.network.repository.NetWorkRepository
@@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.LogUtils
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -80,6 +82,8 @@ class PhotoSelectViewModel @Inject constructor(
navigator = navigator, navigator = navigator,
appState = appState appState = appState
) { ) {
private val paymentHandled = AtomicBoolean(false)
// 图片列表 // 图片列表
private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList()) private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList())
val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow() val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow()
@@ -126,9 +130,9 @@ class PhotoSelectViewModel @Inject constructor(
} }
} }
is UploadPhotoEvent.PaySuccess -> { is UploadPhotoEvent.PaySuccess -> {
// 支付成功,跳转到支付成功页面 // 支付成功,直接进入打印页面
LogUtils.d("PhotoSelectViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, fileMap: ${event.fileMap}") LogUtils.d("PhotoSelectViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, fileMap: ${event.fileMap}")
navigateToPaySuccess(event.orderNumber, event.captureType, event.fileMap) navigateToPrinting(event.orderNumber, event.captureType, event.fileMap)
} }
else -> { else -> {
// 其他事件不处理 // 其他事件不处理
@@ -472,6 +476,7 @@ class PhotoSelectViewModel @Inject constructor(
if (url.isNullOrEmpty()) { if (url.isNullOrEmpty()) {
ToastUtils.show("获取支付二维码失败") ToastUtils.show("获取支付二维码失败")
} else { } else {
paymentHandled.set(false)
_payQrCodeUrl.value = url _payQrCodeUrl.value = url
onSuccess(url) onSuccess(url)
} }
@@ -501,32 +506,60 @@ class PhotoSelectViewModel @Inject constructor(
} }
/** /**
* 跳转到支付成功页面 * 支付成功后直接跳转到打印页面
* @param orderNumber 订单号 * @param orderNumber 订单号
* @param captureType 抓拍类型 * @param captureType 抓拍类型
* @param imageIds 图片ID数组(从支付成功消息中获取) * @param fileMap 支付成功消息中确认的图片数据
*/ */
private fun navigateToPaySuccess(orderNumber: String?, captureType: Int?, fileMap: String) { private fun navigateToPrinting(orderNumber: String?, captureType: Int?, fileMap: String) {
viewModelScope.launch { viewModelScope.launch {
if (captureType != 1) {
LogUtils.i(
"PhotoSelectViewModel",
"忽略非手机上传流程的支付成功消息 - captureType: $captureType"
)
return@launch
}
if (orderNumber.isNullOrBlank()) {
LogUtils.e("PhotoSelectViewModel", "支付成功消息缺少订单号")
ToastUtils.show("支付成功,但订单信息不完整")
return@launch
}
if (fileMap.isBlank()) { if (fileMap.isBlank()) {
LogUtils.e("PhotoSelectViewModel", "未找到对应的 FileMapData,fileMap: $fileMap") LogUtils.e("PhotoSelectViewModel", "未找到对应的 FileMapData,fileMap: $fileMap")
ToastUtils.show("支付成功,但未找到对应的图片数据") ToastUtils.show("支付成功,但未找到对应的图片数据")
return@launch return@launch
} }
// 构建路由参数 val paidFileMapList = try {
val orderNumberParam = orderNumber?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: "" val type = object : TypeToken<List<FileMapData>>() {}.type
val captureTypeParam = captureType?.toString() ?: "" 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,清除选择页面 // 创建 NavOptions,清除选择页面
val navOptions = androidx.navigation.NavOptions.Builder() val navOptions = androidx.navigation.NavOptions.Builder()
.setPopUpTo(com.yzx.kiosk.navigation.routes.AppRoutes.PHOTO_SELECT, inclusive = true) .setPopUpTo(AppRoutes.PHOTO_SELECT, inclusive = true)
.build() .build()
// 跳转到支付成功页面 val route = AppRoutes.buildPrintingRoute(
val route = "${com.yzx.kiosk.navigation.routes.AppRoutes.PAY_SUCCESS}?urls=$fileMap&orderNumber=$orderNumberParam&captureType=$captureTypeParam" photoData = Gson().toJson(paidFileMapList),
orderNumber = orderNumber,
captureType = captureType
)
toPage(route, navOptions) toPage(route, navOptions)
} }
} }
@@ -1,12 +1,12 @@
package com.yzx.kiosk.ui.upload.viewmodel package com.yzx.kiosk.ui.upload.viewmodel
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.yzx.kiosk.base.BaseViewModel import com.yzx.kiosk.base.BaseViewModel
import com.yzx.kiosk.datastore.AppState import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.navigation.AppNavigator import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.network.di.NetworkModule.CLIENT_DOWNLOAD
import com.yzx.kiosk.network.model.request.PrintCompleteRequest import com.yzx.kiosk.network.model.request.PrintCompleteRequest
import com.yzx.kiosk.network.model.request.PrintNotifyRequest import com.yzx.kiosk.network.model.request.PrintNotifyRequest
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
@@ -17,6 +17,7 @@ import com.yzx.kiosk.priter.PrintCallback
import com.yzx.kiosk.priter.PrintRecord import com.yzx.kiosk.priter.PrintRecord
import com.yzx.kiosk.priter.PrintStatusManager import com.yzx.kiosk.priter.PrintStatusManager
import com.yzx.kiosk.priter.PrinterService import com.yzx.kiosk.priter.PrinterService
import com.yzx.kiosk.priter.PrintImageIntegrityValidator
import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.LogUtils
import com.yzx.kiosk.utils.ToastUtils import com.yzx.kiosk.utils.ToastUtils
import com.google.gson.Gson import com.google.gson.Gson
@@ -35,9 +36,12 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import java.net.URL import okhttp3.CacheControl
import okhttp3.OkHttpClient
import okhttp3.Request
import kotlin.coroutines.resume import kotlin.coroutines.resume
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Named
@HiltViewModel @HiltViewModel
class PrintingViewModel @Inject constructor( class PrintingViewModel @Inject constructor(
@@ -46,7 +50,8 @@ class PrintingViewModel @Inject constructor(
private val printerService: PrinterService, private val printerService: PrinterService,
private val appStoreDataSource: AppStoreDataSource, private val appStoreDataSource: AppStoreDataSource,
private val printStatusManager: PrintStatusManager, private val printStatusManager: PrintStatusManager,
private val netWorkRepository: NetWorkRepository private val netWorkRepository: NetWorkRepository,
@Named(CLIENT_DOWNLOAD) private val printDownloadClient: OkHttpClient
) : BaseViewModel( ) : BaseViewModel(
navigator = navigator, navigator = navigator,
appState = appState appState = appState
@@ -55,8 +60,9 @@ class PrintingViewModel @Inject constructor(
private const val TAG = "PrintingViewModel" private const val TAG = "PrintingViewModel"
private const val PRINT_TIME_PER_PHOTO = 15 // 每张照片预计打印时间(秒) private const val PRINT_TIME_PER_PHOTO = 15 // 每张照片预计打印时间(秒)
private const val COUNTDOWN_AFTER_PRINT = 90 // 打印完成后倒计时(秒) private const val COUNTDOWN_AFTER_PRINT = 90 // 打印完成后倒计时(秒)
private const val IMAGE_DOWNLOAD_RETRY_COUNT = 3
} }
/** /**
* 打印状态通知请求数据类 * 打印状态通知请求数据类
*/ */
@@ -66,10 +72,10 @@ class PrintingViewModel @Inject constructor(
val imageId: Int, val imageId: Int,
val printStatus: Int val printStatus: Int
) )
// 打印状态通知请求队列,使用 Channel 确保所有请求都能被处理 // 打印状态通知请求队列,使用 Channel 确保所有请求都能被处理
private val printNotifyChannel = Channel<PrintNotifyTask>(Channel.UNLIMITED) private val printNotifyChannel = Channel<PrintNotifyTask>(Channel.UNLIMITED)
// 是否正在打印 // 是否正在打印
private val _isPrinting = MutableStateFlow(true) private val _isPrinting = MutableStateFlow(true)
val isPrinting: StateFlow<Boolean> = _isPrinting.asStateFlow() val isPrinting: StateFlow<Boolean> = _isPrinting.asStateFlow()
@@ -80,22 +86,22 @@ class PrintingViewModel @Inject constructor(
// 预计等待时间 // 预计等待时间
private val _estimatedTime = MutableStateFlow(0) private val _estimatedTime = MutableStateFlow(0)
val estimatedTime: StateFlow<Int> = _estimatedTime.asStateFlow() val estimatedTime: StateFlow<Int> = _estimatedTime.asStateFlow()
// 预计等待时间倒计时协程 // 预计等待时间倒计时协程
private var estimatedTimeCountdownJob: Job? = null private var estimatedTimeCountdownJob: Job? = null
// 打印完成后倒计时 // 打印完成后倒计时
private val _countdown = MutableStateFlow(COUNTDOWN_AFTER_PRINT) private val _countdown = MutableStateFlow(COUNTDOWN_AFTER_PRINT)
val countdown: StateFlow<Int> = _countdown.asStateFlow() val countdown: StateFlow<Int> = _countdown.asStateFlow()
// 打印记录列表 // 打印记录列表
private val _printRecords = MutableStateFlow<List<PrintRecord>>(emptyList()) private val _printRecords = MutableStateFlow<List<PrintRecord>>(emptyList())
val printRecords: StateFlow<List<PrintRecord>> = _printRecords.asStateFlow() val printRecords: StateFlow<List<PrintRecord>> = _printRecords.asStateFlow()
// 二维码 URL // 二维码 URL
private val _qrCodeUrl = MutableStateFlow<String>("") private val _qrCodeUrl = MutableStateFlow<String>("")
val qrCodeUrl: StateFlow<String> = _qrCodeUrl.asStateFlow() val qrCodeUrl: StateFlow<String> = _qrCodeUrl.asStateFlow()
init { init {
_hotline.value = appStoreDataSource.getHomepageServicePhone() _hotline.value = appStoreDataSource.getHomepageServicePhone()
// 启动一个协程来处理打印状态通知队列,确保串行执行 // 启动一个协程来处理打印状态通知队列,确保串行执行
@@ -103,7 +109,7 @@ class PrintingViewModel @Inject constructor(
processPrintNotifyQueue() processPrintNotifyQueue()
} }
} }
/** /**
* 处理打印状态通知队列,串行执行所有请求 * 处理打印状态通知队列,串行执行所有请求
*/ */
@@ -116,7 +122,7 @@ class PrintingViewModel @Inject constructor(
// Channel.receive() 在协程被取消时会抛出 CancellationException // Channel.receive() 在协程被取消时会抛出 CancellationException
val task = printNotifyChannel.receive() val task = printNotifyChannel.receive()
LogUtils.d(TAG, ">>> 从队列中取出打印状态通知任务 - imageId: ${task.imageId}, printStatus: ${task.printStatus}, orderNumber: ${task.orderNumber}") LogUtils.d(TAG, ">>> 从队列中取出打印状态通知任务 - imageId: ${task.imageId}, printStatus: ${task.printStatus}, orderNumber: ${task.orderNumber}")
// 执行实际的接口调用 // 执行实际的接口调用
executePrintNotify( executePrintNotify(
captureType = task.captureType, captureType = task.captureType,
@@ -142,7 +148,7 @@ class PrintingViewModel @Inject constructor(
} }
LogUtils.d(TAG, ">>> 打印状态通知队列处理协程已结束 <<<") LogUtils.d(TAG, ">>> 打印状态通知队列处理协程已结束 <<<")
} }
/** /**
* 开始打印 * 开始打印
* @param photoUrls 图片URL列表(JSON 格式或旧格式) * @param photoUrls 图片URL列表(JSON 格式或旧格式)
@@ -154,20 +160,20 @@ class PrintingViewModel @Inject constructor(
ToastUtils.show("没有要打印的照片") ToastUtils.show("没有要打印的照片")
return return
} }
if (orderNumber.isEmpty() || captureType == null) { if (orderNumber.isEmpty() || captureType == null) {
LogUtils.e(TAG, "订单号或抓拍类型为空,无法打印") LogUtils.e(TAG, "订单号或抓拍类型为空,无法打印")
ToastUtils.show("订单信息不完整,无法打印") ToastUtils.show("订单信息不完整,无法打印")
return return
} }
viewModelScope.launch { viewModelScope.launch {
_isPrinting.value = true _isPrinting.value = true
_printRecords.value = emptyList() _printRecords.value = emptyList()
// 获取二维码 URL // 获取二维码 URL
loadQrCodeUrl(orderNumber) loadQrCodeUrl(orderNumber)
// 解析图片列表(支持 JSON 格式的 FileMapData 数组) // 解析图片列表(支持 JSON 格式的 FileMapData 数组)
val printItems = parsePhotoList(photoUrls) val printItems = parsePhotoList(photoUrls)
if (printItems.isEmpty()) { if (printItems.isEmpty()) {
@@ -176,16 +182,16 @@ class PrintingViewModel @Inject constructor(
onPrintComplete(orderNumber, captureType) onPrintComplete(orderNumber, captureType)
return@launch return@launch
} }
_estimatedTime.value = printItems.size * PRINT_TIME_PER_PHOTO _estimatedTime.value = printItems.size * PRINT_TIME_PER_PHOTO
// 启动预计等待时间倒计时 // 启动预计等待时间倒计时
startEstimatedTimeCountdown() startEstimatedTimeCountdown()
// 初始化打印机 // 初始化打印机
val initResult = printerService.initDnpPrint() val initResult = printerService.initDnpPrint()
val isPrinterAvailable = initResult && !printStatusManager.isOffline() val isPrinterAvailable = initResult && !printStatusManager.isOffline()
if (!initResult) { if (!initResult) {
LogUtils.e(TAG, "打印机初始化失败,将为所有图片上传打印失败状态") LogUtils.e(TAG, "打印机初始化失败,将为所有图片上传打印失败状态")
ToastUtils.show("打印机初始化失败") ToastUtils.show("打印机初始化失败")
@@ -193,9 +199,9 @@ class PrintingViewModel @Inject constructor(
LogUtils.e(TAG, "打印机离线,将为所有图片上传打印失败状态") LogUtils.e(TAG, "打印机离线,将为所有图片上传打印失败状态")
ToastUtils.show("打印机离线") ToastUtils.show("打印机离线")
} }
val records = mutableListOf<PrintRecord>() val records = mutableListOf<PrintRecord>()
// 循环处理每张图片(无论打印机是否在线,都要上传打印状态) // 循环处理每张图片(无论打印机是否在线,都要上传打印状态)
for ((index, item) in printItems.withIndex()) { for ((index, item) in printItems.withIndex()) {
// 更新预计时间(倒计时协程会自动减少,这里只更新剩余时间) // 更新预计时间(倒计时协程会自动减少,这里只更新剩余时间)
@@ -203,9 +209,9 @@ class PrintingViewModel @Inject constructor(
if (remainingTime > _estimatedTime.value) { if (remainingTime > _estimatedTime.value) {
_estimatedTime.value = remainingTime _estimatedTime.value = remainingTime
} }
LogUtils.d(TAG, "开始处理第 ${index + 1}/${printItems.size} 张图片 - imageId: ${item.id}, url: ${item.originalOssUrl}") LogUtils.d(TAG, "开始处理第 ${index + 1}/${printItems.size} 张图片 - imageId: ${item.id}, url: ${item.originalOssUrl}")
// 1. 检查打印机在线状态(如果初始化失败或离线,直接标记为失败) // 1. 检查打印机在线状态(如果初始化失败或离线,直接标记为失败)
if (!isPrinterAvailable || printStatusManager.isOffline()) { if (!isPrinterAvailable || printStatusManager.isOffline()) {
LogUtils.e(TAG, "打印机不可用,标记为打印失败 - imageId: ${item.id}") LogUtils.e(TAG, "打印机不可用,标记为打印失败 - imageId: ${item.id}")
@@ -222,7 +228,7 @@ class PrintingViewModel @Inject constructor(
appStoreDataSource.addPrintRecord(record) appStoreDataSource.addPrintRecord(record)
continue // 继续处理下一张图片 continue // 继续处理下一张图片
} }
// 2. 下载图片(使用 original_oss_url) // 2. 下载图片(使用 original_oss_url)
val bitmap = downloadImage(item.originalOssUrl) val bitmap = downloadImage(item.originalOssUrl)
@@ -241,14 +247,14 @@ class PrintingViewModel @Inject constructor(
appStoreDataSource.addPrintRecord(record) appStoreDataSource.addPrintRecord(record)
continue // 继续下一张 continue // 继续下一张
} }
// 3. 打印图片,等待回调结果 // 3. 打印图片,等待回调结果
val printResult = printImageWithCallback(bitmap) val printResult = printImageWithCallback(bitmap)
val printStatus = if (printResult.isSuccess) 1 else 2 // 1=成功, 2=失败 val printStatus = if (printResult.isSuccess) 1 else 2 // 1=成功, 2=失败
// 调用 print-notify 接口 // 调用 print-notify 接口
notifyPrintStatus(captureType, orderNumber, item.id, printStatus) notifyPrintStatus(captureType, orderNumber, item.id, printStatus)
val record = PrintRecord( val record = PrintRecord(
fileName = item.originalOssUrl.substringAfterLast("/"), fileName = item.originalOssUrl.substringAfterLast("/"),
printEndTime = System.currentTimeMillis(), printEndTime = System.currentTimeMillis(),
@@ -257,20 +263,20 @@ class PrintingViewModel @Inject constructor(
) )
records.add(record) records.add(record)
appStoreDataSource.addPrintRecord(record) appStoreDataSource.addPrintRecord(record)
// 回收Bitmap // 回收Bitmap
bitmap.recycle() bitmap.recycle()
LogUtils.d(TAG, "打印${if (printResult.isSuccess) "成功" else "失败"} - imageId: ${item.id}, message: ${printResult.message}") LogUtils.d(TAG, "打印${if (printResult.isSuccess) "成功" else "失败"} - imageId: ${item.id}, message: ${printResult.message}")
} }
_printRecords.value = records _printRecords.value = records
// 打印完所有图片,调用 print-complete 接口 // 打印完所有图片,调用 print-complete 接口
onPrintComplete(orderNumber, captureType) onPrintComplete(orderNumber, captureType)
} }
} }
/** /**
* 解析图片列表 * 解析图片列表
* 支持 JSON 格式的 FileMapData 数组或旧格式的 URL 列表 * 支持 JSON 格式的 FileMapData 数组或旧格式的 URL 列表
@@ -279,17 +285,17 @@ class PrintingViewModel @Inject constructor(
if (photoUrls.isEmpty()) { if (photoUrls.isEmpty()) {
return@withContext emptyList() return@withContext emptyList()
} }
// 尝试解析为 JSON 格式(新格式:file_map 数组的 JSON 字符串) // 尝试解析为 JSON 格式(新格式:file_map 数组的 JSON 字符串)
if (photoUrls.size == 1 && photoUrls[0].startsWith("[")) { if (photoUrls.size == 1 && photoUrls[0].startsWith("[")) {
try { try {
val jsonString = java.net.URLDecoder.decode(photoUrls[0], "UTF-8") val jsonString = java.net.URLDecoder.decode(photoUrls[0], "UTF-8")
LogUtils.d(TAG, "解析 JSON 数组字符串: $jsonString") LogUtils.d(TAG, "解析 JSON 数组字符串: $jsonString")
// 使用 Gson 将 JSON 数组反序列化为 FileMapData 对象数组 // 使用 Gson 将 JSON 数组反序列化为 FileMapData 对象数组
val type = object : TypeToken<List<FileMapData>>() {}.type val type = object : TypeToken<List<FileMapData>>() {}.type
val fileMapList: List<FileMapData> = Gson().fromJson(jsonString, type) ?: emptyList() val fileMapList: List<FileMapData> = Gson().fromJson(jsonString, type) ?: emptyList()
// 转换为 PrintItem 列表 // 转换为 PrintItem 列表
return@withContext fileMapList.mapNotNull { fileMap -> return@withContext fileMapList.mapNotNull { fileMap ->
val originalUrl = if (appStoreDataSource.getUseLan()) fileMap.originalLanUrl else fileMap.originalOssUrl ?:fileMap.thumbnailOssUrl 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 队列确保高并发调用时所有请求都能被处理,串行执行避免并发问题 * 使用 Channel 队列确保高并发调用时所有请求都能被处理,串行执行避免并发问题
@@ -351,7 +357,7 @@ class PrintingViewModel @Inject constructor(
imageId = imageId, imageId = imageId,
printStatus = printStatus printStatus = printStatus
) )
// 将请求放入队列,如果队列已满会挂起等待 // 将请求放入队列,如果队列已满会挂起等待
printNotifyChannel.send(task) printNotifyChannel.send(task)
LogUtils.d(TAG, ">>> 打印状态通知请求已加入队列 <<< - imageId: $imageId, printStatus: $printStatus") LogUtils.d(TAG, ">>> 打印状态通知请求已加入队列 <<< - imageId: $imageId, printStatus: $printStatus")
@@ -360,7 +366,7 @@ class PrintingViewModel @Inject constructor(
e.printStackTrace() e.printStackTrace()
} }
} }
/** /**
* 执行实际的打印状态通知接口调用 * 执行实际的打印状态通知接口调用
*/ */
@@ -373,7 +379,7 @@ class PrintingViewModel @Inject constructor(
try { try {
LogUtils.d(TAG, ">>> 开始执行 print-notify 接口调用 <<<") LogUtils.d(TAG, ">>> 开始执行 print-notify 接口调用 <<<")
LogUtils.d(TAG, "参数 - captureType: $captureType, orderNumber: $orderNumber, imageId: $imageId, printStatus: $printStatus") LogUtils.d(TAG, "参数 - captureType: $captureType, orderNumber: $orderNumber, imageId: $imageId, printStatus: $printStatus")
// 如果打印成功,更新打印纸张数(减1) // 如果打印成功,更新打印纸张数(减1)
var remainingPaperNum: Int? = null var remainingPaperNum: Int? = null
if (printStatus == 1) { if (printStatus == 1) {
@@ -385,7 +391,7 @@ class PrintingViewModel @Inject constructor(
remainingPaperNum = newPaperNum remainingPaperNum = newPaperNum
LogUtils.d(TAG, "打印成功,更新纸张数: $currentPaperNum -> $newPaperNum") LogUtils.d(TAG, "打印成功,更新纸张数: $currentPaperNum -> $newPaperNum")
} }
val request = PrintNotifyRequest( val request = PrintNotifyRequest(
captureType = captureType, captureType = captureType,
orderNumber = orderNumber, orderNumber = orderNumber,
@@ -409,7 +415,7 @@ class PrintingViewModel @Inject constructor(
e.printStackTrace() e.printStackTrace()
} }
} }
/** /**
* 打印项数据类 * 打印项数据类
*/ */
@@ -417,7 +423,7 @@ class PrintingViewModel @Inject constructor(
val id: Int, val id: Int,
val originalOssUrl: String val originalOssUrl: String
) )
/** /**
* 打印结果 * 打印结果
*/ */
@@ -425,14 +431,14 @@ class PrintingViewModel @Inject constructor(
val isSuccess: Boolean, val isSuccess: Boolean,
val message: String val message: String
) )
/** /**
* 启动预计等待时间倒计时 * 启动预计等待时间倒计时
*/ */
private fun startEstimatedTimeCountdown() { private fun startEstimatedTimeCountdown() {
// 取消之前的倒计时(如果存在) // 取消之前的倒计时(如果存在)
estimatedTimeCountdownJob?.cancel() estimatedTimeCountdownJob?.cancel()
// 启动新的倒计时 // 启动新的倒计时
estimatedTimeCountdownJob = viewModelScope.launch { estimatedTimeCountdownJob = viewModelScope.launch {
while (_isPrinting.value) { while (_isPrinting.value) {
@@ -444,7 +450,7 @@ class PrintingViewModel @Inject constructor(
} }
} }
} }
/** /**
* 打印完成 * 打印完成
*/ */
@@ -452,13 +458,13 @@ class PrintingViewModel @Inject constructor(
// 停止预计等待时间倒计时 // 停止预计等待时间倒计时
estimatedTimeCountdownJob?.cancel() estimatedTimeCountdownJob?.cancel()
estimatedTimeCountdownJob = null estimatedTimeCountdownJob = null
// 调用 print-complete 接口 // 调用 print-complete 接口
notifyPrintComplete(captureType, orderNumber) notifyPrintComplete(captureType, orderNumber)
_isPrinting.value = false _isPrinting.value = false
_countdown.value = COUNTDOWN_AFTER_PRINT _countdown.value = COUNTDOWN_AFTER_PRINT
// 开始倒计时 // 开始倒计时
viewModelScope.launch { viewModelScope.launch {
while (_countdown.value > 0) { while (_countdown.value > 0) {
@@ -469,19 +475,19 @@ class PrintingViewModel @Inject constructor(
navigator.closeAllExcept(AppRoutes.HOME) navigator.closeAllExcept(AppRoutes.HOME)
} }
} }
/** /**
* 通知打印完成 * 通知打印完成
*/ */
private suspend fun notifyPrintComplete(captureType: Int, orderNumber: String) { private suspend fun notifyPrintComplete(captureType: Int, orderNumber: String) {
try { try {
LogUtils.d(TAG, "调用 print-complete 接口 - captureType: $captureType, orderNumber: $orderNumber") LogUtils.d(TAG, "调用 print-complete 接口 - captureType: $captureType, orderNumber: $orderNumber")
val request = PrintCompleteRequest( val request = PrintCompleteRequest(
captureType = captureType, captureType = captureType,
orderNumber = orderNumber orderNumber = orderNumber
) )
handleResultWithData( handleResultWithData(
flow = netWorkRepository.printComplete(request).asResult(), flow = netWorkRepository.printComplete(request).asResult(),
showToast = false, showToast = false,
@@ -497,24 +503,71 @@ class PrintingViewModel @Inject constructor(
e.printStackTrace() e.printStackTrace()
} }
} }
/** /**
* 下载网络图片 * 下载网络图片
*/ */
private suspend fun downloadImage(url: String): Bitmap? = withContext(Dispatchers.IO) { private suspend fun downloadImage(url: String): Bitmap? = withContext(Dispatchers.IO) {
try { val logUrl = url.substringBefore('?')
val connection = URL(url).openConnection()
connection.connectTimeout = 10000 for (attempt in 1..IMAGE_DOWNLOAD_RETRY_COUNT) {
connection.readTimeout = 10000 try {
connection.getInputStream().use { inputStream -> val request = Request.Builder()
BitmapFactory.decodeStream(inputStream) .url(url)
.get()
.cacheControl(CacheControl.FORCE_NETWORK)
.build()
printDownloadClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw java.io.IOException("HTTP ${response.code}")
}
val body = response.body ?: throw java.io.IOException("响应体为空")
val declaredLength = body.contentLength()
if (declaredLength > PrintImageIntegrityValidator.MAX_DOWNLOAD_BYTES) {
throw java.io.IOException("图片超过大小上限: $declaredLength bytes")
}
// bytes() 会完整读取响应;服务端声明了 Content-Length 时,OkHttp 会校验实际长度。
val bytes = body.bytes()
if (declaredLength >= 0L && declaredLength != bytes.size.toLong()) {
throw java.io.IOException(
"响应长度不一致: declared=$declaredLength, actual=${bytes.size}"
)
}
val decoded = PrintImageIntegrityValidator.decodeDownloadedImage(bytes)
LogUtils.d(
TAG,
"打印图片校验成功 - url=$logUrl, attempt=$attempt, bytes=${bytes.size}, " +
"format=${decoded.format}, mime=${decoded.mimeType}, " +
"source=${decoded.sourceWidth}x${decoded.sourceHeight}, " +
"decoded=${decoded.bitmap.width}x${decoded.bitmap.height}, " +
"sample=${decoded.sampleSize}, sha256=${decoded.sha256.take(16)}"
)
return@withContext decoded.bitmap
}
} catch (e: CancellationException) {
throw e
} catch (e: OutOfMemoryError) {
LogUtils.e(TAG, "打印图片解码内存不足 - url=$logUrl, attempt=$attempt")
return@withContext null
} catch (e: Exception) {
LogUtils.e(
TAG,
"打印图片下载或校验失败 - url=$logUrl, attempt=$attempt/$IMAGE_DOWNLOAD_RETRY_COUNT, error=${e.message}"
)
if (attempt < IMAGE_DOWNLOAD_RETRY_COUNT) {
delay(500L * attempt)
}
} }
} catch (e: Exception) {
LogUtils.e(TAG, "下载图片失败: $url, ${e.message}")
null
} }
LogUtils.e(TAG, "打印图片连续 $IMAGE_DOWNLOAD_RETRY_COUNT 次校验失败,禁止送入打印机 - url=$logUrl")
null
} }
/** /**
* 获取二维码 URL * 获取二维码 URL
*/ */
@@ -544,6 +597,6 @@ class PrintingViewModel @Inject constructor(
} }
} }
} }
} }
@@ -0,0 +1,32 @@
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 `printing route leaves missing order metadata empty`() {
val route = AppRoutes.buildPrintingRoute("[]", null, null)
assertEquals("printing?urls=%5B%5D&orderNumber=&captureType=", route)
}
}
@@ -0,0 +1,37 @@
package com.yzx.kiosk.network.service
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/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 `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,50 @@
package com.yzx.kiosk.ui.face.resource
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
import java.nio.file.Files
class FaceCaptureFileStoreTest {
private lateinit var cacheDirectory: File
@Before
fun setUp() {
cacheDirectory = Files.createTempDirectory("face-capture-test").toFile()
}
@After
fun tearDown() {
cacheDirectory.deleteRecursively()
}
@Test
fun `cleanup removes owned and legacy captures only`() {
val captureDirectory = File(
cacheDirectory,
FaceCaptureFileStore.CAPTURE_DIRECTORY,
).apply { mkdirs() }
val ownedCapture = File(captureDirectory, "capture_1.jpg").apply { writeText("face") }
val legacyCapture = File(cacheDirectory, "IMG_20260825.jpg").apply { writeText("face") }
val unrelatedJpeg = File(cacheDirectory, "holiday.jpg").apply { writeText("keep") }
val similarLegacyFile = File(cacheDirectory, "IMG_notes.txt").apply { writeText("keep") }
val deleted = FaceCaptureFileStore.cleanupOrphans(cacheDirectory)
assertEquals(2, deleted)
assertFalse(ownedCapture.exists())
assertFalse(legacyCapture.exists())
assertTrue(unrelatedJpeg.exists())
assertTrue(similarLegacyFile.exists())
}
@Test
fun `cleanup is idempotent`() {
assertEquals(0, FaceCaptureFileStore.cleanupOrphans(cacheDirectory))
assertEquals(0, FaceCaptureFileStore.cleanupOrphans(cacheDirectory))
}
}
@@ -0,0 +1,32 @@
package com.yzx.kiosk.ui.face.resource
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FaceResourceHelpersTest {
@Test
fun `sample size bounds large source before final scale`() {
assertEquals(4, FaceThumbnailDecoder.calculateInSampleSize(4000, 3000, 720))
assertEquals(1, FaceThumbnailDecoder.calculateInSampleSize(1280, 720, 720))
assertEquals(1, FaceThumbnailDecoder.calculateInSampleSize(0, 0, 720))
}
@Test
fun `capture gate permits only one active capture`() {
val gate = CaptureGate()
val firstToken = gate.tryAcquire()
assertTrue(firstToken != null)
assertEquals(null, gate.tryAcquire())
assertTrue(gate.release(firstToken!!))
val secondToken = gate.tryAcquire()
assertTrue(secondToken != null)
assertFalse(gate.release(firstToken))
assertEquals(null, gate.tryAcquire())
assertTrue(gate.release(secondToken!!))
}
}
@@ -0,0 +1,102 @@
package com.yzx.kiosk.ui.face.viewmodel
import com.google.gson.Gson
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
import com.yzx.kiosk.network.model.response.PaySuccessMessageData
import com.yzx.kiosk.network.model.response.PaySuccessMessageResponse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FacePayStatusDecisionTest {
@Test
fun `pending and unknown statuses continue polling`() {
listOf(null, 10, 20, 0, 70).forEach { status ->
assertEquals(
FacePayStatusDecision.CONTINUE_POLLING,
decideFacePayStatus(status)
)
}
}
@Test
fun `completed status completes payment`() {
assertEquals(
FacePayStatusDecision.COMPLETE_PAYMENT,
decideFacePayStatus(30)
)
}
@Test
fun `cancel and refund statuses stop polling`() {
listOf(40, 50, 60).forEach { status ->
assertEquals(
FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS,
decideFacePayStatus(status)
)
}
}
@Test
fun `valid face payment message is accepted`() {
assertTrue(isValidFacePaySuccessMessage("ORDER-1", message()))
}
@Test
fun `empty or mismatched payment data is rejected`() {
assertFalse(isValidFacePaySuccessMessage("ORDER-1", null))
assertFalse(isValidFacePaySuccessMessage("OTHER", message()))
assertFalse(isValidFacePaySuccessMessage("ORDER-1", message(type = 4)))
assertFalse(isValidFacePaySuccessMessage("ORDER-1", message(captureType = 1)))
assertFalse(isValidFacePaySuccessMessage("ORDER-1", message(imageIds = emptyList())))
}
@Test
fun `pay status request uses backend order number field`() {
val json = Gson().toJson(PaySuccessMessageRequest("ORDER-1"))
assertEquals("{\"order_number\":\"ORDER-1\"}", json)
}
@Test
fun `pay status response deserializes nested websocket payload`() {
val json = """
{
"order_status": 30,
"order_status_name": "已完成",
"sn": "OSCAR_DEVICE_SN",
"type": 5,
"data": {
"order_number": "ORDER-1",
"capture_type": 2,
"image_id": [335, 340]
}
}
""".trimIndent()
val response = Gson().fromJson(json, PaySuccessMessageResponse::class.java)
assertEquals(30, response.orderStatus)
assertEquals("ORDER-1", response.data?.orderNumber)
assertEquals(listOf(335, 340), response.data?.imageIds)
assertTrue(isValidFacePaySuccessMessage("ORDER-1", response))
}
private fun message(
type: Int = 5,
captureType: Int = 2,
imageIds: List<Int> = listOf(335, 340)
) = PaySuccessMessageResponse(
orderStatus = 30,
orderStatusName = "已完成",
sn = "OSCAR_DEVICE_SN",
type = type,
data = PaySuccessMessageData(
orderNumber = "ORDER-1",
captureType = captureType,
imageIds = imageIds
)
)
}
+3 -5
View File
@@ -92,7 +92,7 @@ sequenceDiagram
K->>API: get-pay-url 获取支付二维码 K->>API: get-pay-url 获取支付二维码
User->>API: 手机支付 User->>API: 手机支付
WS-->>K: code=5 支付成功及订单信息 WS-->>K: code=5 支付成功及订单信息
K-->>User: 支付成功,确认要打印的照片 K-->>User: 支付成功,直接进入打印页
K->>P: 逐张下载、处理并打印 K->>P: 逐张下载、处理并打印
K->>API: print-notify 上报每张结果 K->>API: print-notify 上报每张结果
K->>API: print-complete 上报订单完成 K->>API: print-complete 上报订单完成
@@ -104,7 +104,6 @@ sequenceDiagram
首页 首页
-> 上传照片二维码页 UploadPhotoScreen -> 上传照片二维码页 UploadPhotoScreen
-> 照片选择页 PhotoSelectScreen -> 照片选择页 PhotoSelectScreen
-> 支付成功页 PaySuccessScreen
-> 打印页 PrintingScreen -> 打印页 PrintingScreen
-> 自动返回首页 -> 自动返回首页
``` ```
@@ -112,8 +111,7 @@ sequenceDiagram
核心类: 核心类:
- [`UploadPhotoViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/UploadPhotoViewModel.kt):加载上传二维码,监听扫码和文件列表事件 - [`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):选片、计价、获取支付二维码 - [`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):支付后再次确认打印照片
- [`PrintingViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt):逐张下载、打印、记录并上报结果 - [`PrintingViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt):逐张下载、打印、记录并上报结果
服务端通过 `file_map` 同时下发 OSS 和局域网地址。客户端根据设置中的“使用 LAN”开关选择 `*_lan_url` 或 `*_oss_url`。 服务端通过 `file_map` 同时下发 OSS 和局域网地址。客户端根据设置中的“使用 LAN”开关选择 `*_lan_url` 或 `*_oss_url`。
@@ -130,7 +128,7 @@ sequenceDiagram
4. 服务根据人脸相似度返回当天匹配的照片。 4. 服务根据人脸相似度返回当天匹配的照片。
5. 终端展示缩略图,游客选择照片。 5. 终端展示缩略图,游客选择照片。
6. 客户端请求后端计算价格并生成支付二维码。 6. 客户端请求后端计算价格并生成支付二维码。
7. WebSocket 收到支付成功事件后进入支付成功页和打印页。 7. WebSocket 或 HTTP 轮询确认支付成功后直接进入打印页。
主要代码: 主要代码: