16 changed files with 5630 additions and 12 deletions
@@ -44,6 +44,29 @@ class LocalAudioPlaybackTest {
}
}
@Test fun electronicCompletionSwitchesToDedicatedClipAndPreservesPrintAudio() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
lateinit var service: LocalAudioPlayService
instrumentation.runOnMainSync { service = LocalAudioPlayService(instrumentation.targetContext) }
try {
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.PRINT_SUCCESS) }
awaitPlaying(service)
assertEquals(R.raw.print_complete_page, service.currentResId.value)
instrumentation.runOnMainSync {
service.playByRoute(AppRoutes.buildElectronicCompletionRoute("AUDIO-TEST-ORDER"))
}
awaitPlaying(service)
assertEquals(R.raw.electronic_complete_page, service.currentResId.value)
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.PRINT_SUCCESS) }
awaitPlaying(service)
assertEquals(R.raw.print_complete_page, service.currentResId.value)
} finally {
instrumentation.runOnMainSync { service.release() }
}
}
private fun awaitPlaying(service: LocalAudioPlayService) {
val deadline = System.currentTimeMillis() + 5000
while (!service.isPlaying.value && System.currentTimeMillis() < deadline) Thread.sleep(20)
@@ -102,7 +102,7 @@ class RecentPhotosFlowTest {
req.body!!.writeTo(buffer)
val ids = Gson().fromJson(buffer.readUtf8(), com.google.gson.JsonObject::class.java).getAsJsonArray("image_id")
val amount = "${ids.size() * 2}.00"
val extra = electronicAmount?.let { ",\"price_electronic\":\"$it\",\"amount_electronic\":\"$it\"" }.orEmpty()
val extra = electronicAmount?.let { ",\"price_electronic\":\"$it\",\"amount_electronic\":\"${it.toBigDecimal().multiply(ids.size().toBigDecimal()).toPlainString()}\"" }.orEmpty()
"""{"code":100000,"data":{"price_image":"2.00","amount":"$amount"$extra}}"""
}
req.url.encodedPath.endsWith("get-pay-url") && payFails ->
@@ -467,7 +467,10 @@ class RecentPhotosFlowTest {
compose.onNodeWithText("二维码加载失败").assertIsDisplayed()
qrFails = false
compose.onNodeWithText("重新加载").performClick()
compose.waitUntil(10_000) { completionVm!!.qrCodeUrl.value.isNotEmpty() }
// URL receipt precedes the background QR bitmap generation.
compose.waitUntil(10_000) {
compose.onAllNodesWithContentDescription("电子版照片下载二维码").fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithContentDescription("电子版照片下载二维码").assertIsDisplayed()
compose.onNodeWithText("请在屏幕下方拿取照片").assertDoesNotExist()
screenshot("electronic-completion")
@@ -479,12 +482,62 @@ class RecentPhotosFlowTest {
assertEquals(papersBefore, app.appStoreDataSource.getRemainingPaperNum())
}
@Test fun emptyRecentPhotosStillShowConfiguredUnitPrices() {
electronicAmount = "1.00"
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
compose.waitForIdle()
assertTrue("Entering recent photos must request prices even when the photo list is empty",
requests.any { it.url.encodedPath.endsWith("verify-result") })
compose.waitUntil(10_000) { !resultVm.quoteLoading.value }
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
compose.onNodeWithText("购买打印照片\n0元").assertIsNotEnabled()
compose.onNodeWithText("购买电子照片\n0元").assertIsNotEnabled()
}
@Test fun recentPhotosSelectionUpdatesBothQuotesWithoutReloadReset() {
electronicAmount = "1.00"
val bitmap = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
val photos = (1..2).map { id ->
val file = File(context.cacheDir, "recent-price-$id.png")
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
val url = file.toURI().toString()
FaceSearchResult(id, url, url, url, url)
}
recentBody = Gson().toJson(mapOf("count" to 2, "results" to photos))
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.READY && !resultVm.quoteLoading.value }
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
compose.onNodeWithText("购买电子照片\n0元").assertIsNotEnabled()
assertEquals(1, requests.count { it.url.encodedPath.endsWith("verify-result") })
compose.runOnIdle { resultVm.toggleSelectAll() }
compose.waitUntil(10_000) { !resultVm.quoteLoading.value && resultVm.electronicTotal.value == "2.00" }
compose.onNodeWithText("购买打印照片\n4元").assertIsEnabled()
compose.onNodeWithText("购买电子照片\n2元").assertIsEnabled()
compose.runOnIdle { resultVm.loadRecentPhotos() }
compose.waitForIdle()
assertEquals("4.00", resultVm.totalPrice.value)
assertEquals("2.00", resultVm.electronicTotal.value)
assertEquals(2, requests.count { it.url.encodedPath.endsWith("verify-result") })
compose.runOnIdle { resultVm.toggleSelectAll() }
compose.waitUntil(10_000) { !resultVm.quoteLoading.value && resultVm.totalPrice.value == "0.00" }
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
compose.onNodeWithText("购买电子照片\n0元").assertIsNotEnabled()
}
@Test fun errorCanRetryIntoEmptyState() {
electronicAmount = "1.00"
recentCode = 403
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.ERROR }
compose.onNodeWithText("照片加载失败,请重试").assertIsDisplayed()
compose.onNodeWithText("购买打印照片\n--元").assertIsNotEnabled()
compose.waitUntil(10_000) { !resultVm.quoteLoading.value }
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
compose.onNodeWithText("购买打印照片\n0元").assertIsNotEnabled()
recentCode = 200
compose.onNodeWithText("重试").performClick()
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
@@ -47,6 +47,7 @@ class LocalAudioPlayService @Inject constructor(
AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page,
AppRoutes.PRINTING to R.raw.printing_page,
AppRoutes.PRINT_SUCCESS to R.raw.print_complete_page,
ELECTRONIC_COMPLETION_AUDIO_KEY to R.raw.electronic_complete_page,
// 特殊状态页面
"face_recognition_recognizing" to R.raw.face_recognition_recognizing_page,
"face_recognition_failed" to R.raw.face_recognition_failed_page,
@@ -112,10 +113,6 @@ class LocalAudioPlayService @Inject constructor(
// 同一结果页根据入口选择播报,避免最近照片误播识别成功。
val audioKey = localAudioKeyForRoute(route)
if (audioKey == ELECTRONIC_COMPLETION_AUDIO_KEY) {
stop()
return
}
val audioResId = ROUTE_TO_AUDIO_MAP[audioKey]
if (audioResId == null) {
@@ -3,7 +3,7 @@ package com.yzx.kiosk.audio
import com.yzx.kiosk.navigation.routes.AppRoutes
import java.net.URLDecoder
internal const val ELECTRONIC_COMPLETION_AUDIO_KEY = "electronic_completion_silent"
internal const val ELECTRONIC_COMPLETION_AUDIO_KEY = "electronic_completion"
internal const val RECENT_PHOTOS_AUDIO_KEY = "recent_photos_result"
@@ -74,7 +74,11 @@ class FaceRecognitionResultViewModel @Inject constructor(
toPage(AppRoutes.buildRecentPhotosRoute())
}
fun loadRecentPhotos() = recentLoader.load()
fun loadRecentPhotos() {
// Pricing belongs to the page, not to a successful/non-empty photo response.
if (recentPhotosState.value == RecentPhotosState.IDLE) verifyResult(emptyList())
recentLoader.load()
}
fun retryRecentPhotos() = recentLoader.load(retry = true)
// 原始结果列表(用于获取图片id)
@@ -177,6 +181,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
// Returning from a pushed recent-photos page must retain selection, pricing and image state.
if (faceResultsInitialized) return
faceResultsInitialized = true
verifyResult(emptyList())
viewModelScope.launch { applyPhotoList(results) }
}
@@ -215,8 +220,6 @@ class FaceRecognitionResultViewModel @Inject constructor(
// 初始化加载状态
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
// 页面初始化时调用验证接口(空列表,type: 2),获取默认价格
verifyResult(emptyList())
LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新")
}
Binary file not shown.
@@ -5,7 +5,7 @@ import org.junit.Assert.assertEquals
import org.junit.Test
class LocalAudioRouteTest {
@Test fun `electronic completion suppresses printing audio while print routes retain it`() {
@Test fun `electronic completion uses its own audio while print routes retain theirs`() {
assertEquals(ELECTRONIC_COMPLETION_AUDIO_KEY, localAudioKeyForRoute(AppRoutes.buildElectronicCompletionRoute("ORDER")))
assertEquals(AppRoutes.PRINTING, localAudioKeyForRoute(AppRoutes.buildPrintingRoute("[]", "ORDER", 1)))
assertEquals(AppRoutes.PRINTING, localAudioKeyForRoute("printing?urls=electronic%3Dtrue"))
@@ -0,0 +1,106 @@
Starting a Gradle Daemon (subsequent builds will be faster)
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:checkKotlinGradlePluginConfigurationErrors SKIPPED
> Task :app:dataBindingMergeDependencyArtifactsDebug UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:dataBindingGenBaseClassesDebug UP-TO-DATE
> Task :app:generateDebugBuildConfig UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest
> Task :app:processDebugManifest
> Task :app:javaPreCompileDebug UP-TO-DATE
> Task :app:preDebugUnitTestBuild UP-TO-DATE
> Task :app:javaPreCompileDebugUnitTest UP-TO-DATE
> Task :app:preDebugAndroidTestBuild SKIPPED
> Task :app:generateDebugAndroidTestResValues UP-TO-DATE
> Task :app:extractProguardFiles UP-TO-DATE
> Task :app:processDebugManifestForPackage
> Task :app:processDebugResources
> Task :app:kspDebugKotlin UP-TO-DATE
> Task :app:compileDebugKotlin UP-TO-DATE
> Task :app:compileDebugJavaWithJavac UP-TO-DATE
> Task :app:hiltAggregateDepsDebug UP-TO-DATE
> Task :app:hiltJavaCompileDebug UP-TO-DATE
> Task :app:transformDebugClassesWithAsm UP-TO-DATE
> Task :app:processDebugJavaRes UP-TO-DATE
> Task :app:bundleDebugClassesToCompileJar UP-TO-DATE
> Task :app:bundleDebugClassesToRuntimeJar
> Task :app:generateDebugAndroidTestLintModel
> Task :app:generateDebugLintReportModel
> Task :app:generateDebugUnitTestLintModel
> Task :app:kspDebugUnitTestKotlin
> Task :app:compileDebugUnitTestKotlin
> Task :app:compileDebugUnitTestJavaWithJavac NO-SOURCE
> Task :app:hiltAggregateDepsDebugUnitTest UP-TO-DATE
> Task :app:hiltJavaCompileDebugUnitTest NO-SOURCE
> Task :app:processDebugUnitTestJavaRes UP-TO-DATE
> Task :app:transformDebugUnitTestClassesWithAsm
> Task :app:testDebugUnitTest
> Task :app:lintAnalyzeDebugUnitTest
> Task :app:lintAnalyzeDebugAndroidTest
> Task :app:lintAnalyzeDebug
> Task :app:lintReportDebug
Wrote HTML report to file:///Users/hanqiu/Desktop/KIOSK/app/build/reports/lint-results-debug.html
Lint found 4 errors, 329 warnings and 8 hints. First failure:
/Users/hanqiu/Desktop/KIOSK/app/src/main/AndroidManifest.xml: Error: When targeting Android 13 or higher, posting a permission requires holding the POST_NOTIFICATIONS permission (usage from com.bumptech.glide.request.target.NotificationTarget) [NotificationPermission]
> Task :app:lintDebug FAILED
Lint found 4 errors, 329 warnings, 8 hints. First failure:
/Users/hanqiu/Desktop/KIOSK/app/src/main/AndroidManifest.xml: Error: When targeting Android 13 or higher, posting a permission requires holding the POST_NOTIFICATIONS permission (usage from com.bumptech.glide.request.target.NotificationTarget) [NotificationPermission]
Explanation for issues of type "NotificationPermission":
When targeting Android 13 and higher, posting permissions requires holding
the runtime permission android.permission.POST_NOTIFICATIONS.
The full lint text report is located at:
/Users/hanqiu/Desktop/KIOSK/app/build/intermediates/lint_intermediate_text_report/debug/lintReportDebug/lint-results-debug.txt
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':app:lintDebug'.
> Lint found errors in the project; aborting build.
Fix the issues identified by lint, or create a baseline to see only new errors.
To create a baseline, run `gradlew updateLintBaseline` after adding the following to the module's build.gradle file:
```
android {
lint {
baseline = file("lint-baseline.xml")
}
}
```
For more details, see https://developer.android.com/studio/write/lint#snapshot
Lint found 4 errors, 329 warnings, 8 hints. First failure:
/Users/hanqiu/Desktop/KIOSK/app/src/main/AndroidManifest.xml: Error: When targeting Android 13 or higher, posting a permission requires holding the POST_NOTIFICATIONS permission (usage from com.bumptech.glide.request.target.NotificationTarget) [NotificationPermission]
Explanation for issues of type "NotificationPermission":
When targeting Android 13 and higher, posting permissions requires holding
the runtime permission android.permission.POST_NOTIFICATIONS.
The full lint text report is located at:
/Users/hanqiu/Desktop/KIOSK/app/build/intermediates/lint_intermediate_text_report/debug/lintReportDebug/lint-results-debug.txt
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
BUILD FAILED in 31s
43 actionable tasks: 18 executed, 25 up-to-date
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
# 单元测试结果(2026-09-20)
来源:本次 :app:testDebugUnitTest 生成的 JUnit XML。
| 测试类 | 用例 | 失败 | 错误 | 跳过 |
| --- | ---: | ---: | ---: | ---: |
| com.yzx.kiosk.ExampleUnitTest | 1 | 0 | 0 | 0 |
| com.yzx.kiosk.audio.LocalAudioRouteTest | 4 | 0 | 0 | 0 |
| com.yzx.kiosk.navigation.routes.AppRoutesTest | 3 | 0 | 0 | 0 |
| com.yzx.kiosk.network.service.FaceSearchUrlTest | 4 | 0 | 0 | 0 |
| com.yzx.kiosk.network.service.RecentPhotosServiceTest | 2 | 0 | 0 | 0 |
| com.yzx.kiosk.ui.face.resource.FaceCaptureFileStoreTest | 2 | 0 | 0 | 0 |
| com.yzx.kiosk.ui.face.resource.FaceResourceHelpersTest | 2 | 0 | 0 | 0 |
| com.yzx.kiosk.ui.face.viewmodel.FacePayStatusDecisionTest | 7 | 0 | 0 | 0 |
| com.yzx.kiosk.ui.face.viewmodel.FacePurchaseTest | 11 | 0 | 0 | 0 |
| com.yzx.kiosk.ui.face.viewmodel.RecentPhotosLoaderTest | 4 | 0 | 0 | 0 |
| com.yzx.kiosk.websocket.PurchaseCompletedMessageTest | 4 | 0 | 0 | 0 |
总计:44 项,失败 0,错误 0,跳过 0。
@@ -0,0 +1,8 @@
# 电子版完成页语音
- 来源:用户提供的 `9月15日.wav`。
- 项目资源名称:`app/src/main/res/raw/electronic_complete_page.wav`。
- 原先从打印完成提示音裁剪得到的版本已被替换。
- 按原文件复制,未裁剪、转码或调整音量;48 kHz、双声道、16-bit PCM WAV,时长 5.632 秒。
- 进入电子版完成页时通过 `LocalAudioPlayService` 播放此资源。
- 打印完成页继续使用 `print_complete_page.wav`。
Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 968 KiB

@@ -0,0 +1,247 @@
# KIOSK 工程代码优化审查报告
审查日期:2026-09-20
代码基线:`6d81f1a`,以本次工作区实际内容为准。
工程:Android / Kotlin / Jetpack Compose,包名 `com.yzx.kiosk`。
## 1. 结论与范围
建议先修复打印结果、订单通知和连接生命周期,再处理安全配置、文件资源与工程质量。当前主要风险并非页面代码写法,而是设备实际状态、本地记录和服务端订单状态缺少可靠的一致性保障。
本次完成源码清单扫描,并重点阅读应用入口、构建配置、打印服务、两条打印流程、WebSocket、网络结果封装、上传下载、数据库、升级、人脸识别与购片、资源管理及测试。主源码目录共 195 个 Kotlin/Java 文件,约 28,254 行;这不是逐行穷尽审计,也不包含本地闭源 AAR/JAR 的内部实现。
本次仅新增审查文档与验证记录,未修改业务代码。原有 `gradlew` 修改和未跟踪的 UI 预览文件予以保留。
证据分为三类:**源码确认**表示代码行为可直接定位;**测试确认**表示本次实际执行得到结果;**待实机验证**表示发生概率、设备表现或外部协议仍需验证。源码确认的风险不等同于已经发生过线上故障。
优先级定义:P1 为影响业务正确性、设备持续工作或敏感配置的问题;P2 为可靠性与工程质量问题;P3 为需要测量后推进的结构和性能改善。
| 编号 | 优先级 | 优化项 | 证据 |
| --- | --- | --- | --- |
| 01 | P1 | 打印成功应等待真实终态 | 源码确认,设备行为待实机验证 |
| 02 | P1 | 打印明细通知与订单完成应持久化、顺序提交 | 源码确认 |
| 03 | P1 | 打印机初始状态和初始化结果判定 | 源码确认 |
| 04 | P1 | 隐私取片队列幂等与单消费者 | 源码确认,重投协议待确认 |
| 05 | P1 | WebSocket 主动停止和旧连接回调隔离 | 源码确认 |
| 06 | P1 | 签名材料和固定管理密码 | 源码确认 |
| 07 | P1 | 打印中间文件清理和磁盘上限 | 源码确认 |
| 08 | P2 | 隐私取片使用原图、统一图片处理 | 源码确认,画质待实机对比 |
| 09 | P2 | 图片下载在读取阶段限制内存 | 源码确认 |
| 10 | P2 | 网络错误结果保持类型安全 | 源码确认 |
| 11 | P2 | APK 下载校验与缓存复用 | 源码确认 |
| 12 | P2 | 数据库迁移与 schema 管理 | 源码确认 |
| 13 | P2 | 凭证存储、备份和授权状态 | 源码确认 |
| 14 | P2 | Wrapper、Lint 与核心回归测试 | 测试及源码确认 |
| 15 | P2(启用前) | 云传输并发与取消句柄 | 源码确认,当前未找到业务入口 |
| 16 | P3 | 包体、诊断能力和大类拆分 | 优化建议,收益待测量 |
## 2. 优先修复项
### 01|打印入队被当成打印完成(P1)
位置:[PrinterService.kt:319](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt:319)。
**现象与影响:** `printQueue.addJob(job)` 后,RX1 分支立即设置空闲并回调成功;QW410 分支固定等待 18 秒后回调成功。业务层没有等待 SDK 的成功/失败终态。任务提交后缺纸、断连、卡纸或排队超过预估时间时,本地记录、纸张扣减和订单通知可能提前认定成功。现有 `printMutex` 只能保证该方法串行执行;RX1 方法提前返回后,它不能保证物理打印已经完成。
**建议:** 将“已提交”“设备执行中”“成功”“失败”“结果未知”分开。根据实际 SDK 支持,接入任务回调或按任务标识查询终态;等待超时进入待核对状态。业务成功、耗材记账与对外回执均由同一个终态驱动。先确认厂商 SDK 的终态语义,再实现适配层。
**验收:** 分别在 RX1/QW410 上覆盖正常出片、入队后拔线、缺纸、卡纸、长队列和超时;无可靠成功结果时不得显示完成或发送成功回执。
### 02|通知队列未等待网络完成,且没有持久化补偿(P1)
位置:[PrintingViewModel.kt:116](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt:116)、[PrintingViewModel.kt:373](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt:373)、[PrintingViewModel.kt:457](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt:457)、[ResultHandler.kt:27](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/network/result/ResultHandler.kt:27)。
**现象与影响:** Channel 消费者调用 `executePrintNotify()`,但底层 `handleResultWithData()` 再次 `scope.launch`,没有等待请求结束。`print-complete` 又在另一条路径直接发起,没有等待明细通知全部确认。慢网情况下完成通知可能先到;退出页面或进程终止会丢失内存中的待通知状态。失败回调仅记录日志。
**建议:** 核心业务接口改为可等待的挂起调用;把打印事实和待发送回执写入 Room,在同一订单内按明细确认、订单完成的顺序推进。为每张照片/每次授权打印建立幂等标识,重试采用退避并能跨进程恢复。
纸张扣减应依据**可靠的物理打印成功事实**,并保证仅记账一次。网络通知失败时应重试同步,不应简单把已实际消耗的纸张加回;否则会引入新的库存偏差。
**验收:** 延迟首条明细响应、断网、页面退出、进程重启后,回执不丢失且顺序符合服务端约定;重试不会重复扣纸。
### 03|默认在线,初始化又未检查端口查询结果(P1)
位置:[PrintStatusManager.kt:24](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/priter/PrintStatusManager.kt:24)、[PrinterService.kt:59](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt:59)。
**现象与影响:** `_isOnline` 默认是 `true`。`initDnpPrint()` 虽取得 `portNum`,却没有判定返回值和端口有效性,就读取预分配数组的第一项,保存 ID 并设置在线。无打印机但查询未抛异常时,也可能报告连接成功;后续打印才因 ID 为 0 失败。
**建议:** 初始状态设为未知或离线;结合 SDK 返回语义校验端口数量、设备 ID 和实际可用状态。重连、USB 拔插、忙碌状态应有一致的状态转换,避免初始化把正在执行的任务覆盖为空闲。
**验收:** 不接设备冷启动、接入/拔出设备、异常返回值和打印中重新探测时,界面与心跳均不误报可接单。
### 04|隐私取片缺少持久化去重,消费者启动存在竞争(P1)
位置:[PrintQueueManager.kt:34](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/PrintQueueManager.kt:34)、[PrintQueueManager.kt:104](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/PrintQueueManager.kt:104)、[PrivacyPrintService.kt:87](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt:87)、[WebSocketService.kt:890](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt:890)。
**现象与影响:** 完成任务直接从内存列表移除,相同订单消息再次到达时可以重新入队;进程重启也没有去重记录。`processNextTask()` 的 `isProcessing` 检查和置位不在一个原子操作中,多个 IO 协程可能同时通过检查;队列自身加锁不能保护外部消费者状态及单个 `currentPrintTask`。
**建议:** 使用一个受管理的消费者串行领取任务;持久化任务状态与去重标识。和服务端明确“重复投递”与“用户授权补打”的区别,以消息 ID 或打印批次 ID 区分,避免把合法补打一律拒绝。异常终止后,已经提交硬件但终态未知的任务先核对,不能盲目再次打印。
**验收:** 同一消息连续投递、并发投递、完成后重投和重启后重投均只执行一次;独立授权补打仍可执行。
### 05|主动停止 WebSocket 后可能自动重连(P1)
位置:[WebSocketService.kt:185](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt:185)、[WebSocketService.kt:695](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt:695)、[WebSocketService.kt:717](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt:717)。
**现象与影响:** `stop()` 取消已有重连并关闭连接,但关闭后到达的 `onClosed()` 无条件调用 `scheduleReconnect()`,代码没有停止标志。旧连接的 `onClosed/onFailure` 还会无条件将共享 `webSocket` 清空,可能干扰已经建立的新连接。多处临时创建的 CoroutineScope 使停止时难以完整取消工作。
**建议:** 建立统一作用域和连接状态机,持久保留本次运行是否允许连接的标志;回调携带连接代次,只允许当前连接修改状态。将 start/stop、心跳和重连事件集中到同一消费者或互斥保护范围内。
**验收:** 主动停止后等待多个重连周期也不恢复连接;连续 start/stop、旧连接延迟失败以及网络切换时,始终只有一个有效连接与心跳任务。
### 06|签名材料受版本管理,管理密码固定(P1)
位置:[app/build.gradle.kts:112](/Users/hanqiu/Desktop/KIOSK/app/build.gradle.kts:112)、[app/build.gradle.kts:151](/Users/hanqiu/Desktop/KIOSK/app/build.gradle.kts:151)、[HomeViewModel.kt:162](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/ui/home/viewmodel/HomeViewModel.kt:162)、[HomeViewModel.kt:323](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/ui/home/viewmodel/HomeViewModel.kt:323)。
**确认事实:** 签名密码写在 Gradle 配置中,Release 选择名为 `debug` 的签名配置;`git ls-files` 确认两个 `.jks` 文件仍受版本管理。虽然 `.gitignore` 已排除此扩展名,但不能移除已经跟踪的文件。设置入口仍将输入与固定常量比较。本文不复制任何口令。
**建议:** 签名凭证转移至受控的本机配置或 CI Secret,并处理仓库中已跟踪的材料及历史泄露范围;先核实在用设备的签名证书和升级路径,再迁移签名,避免直接替换导致无法覆盖安装。管理入口采用设备独立认证或短期授权,并限制连续失败。
**验收:** 工作树及发布配置没有明文签名密码;发布包证书符合既定升级策略;仅拿到 APK 不能获得所有设备共用的管理口令。本次未重新构建并比较签名证书。
### 07|打印 BMP 无清理策略,长期运行可能耗尽磁盘(P1)
位置:[PrinterService.kt:253](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt:253)。
**现象与影响:** 每张照片生成带时间戳的 `ProcessedPhotos/*_print.bmp`。检索主源码未发现针对该目录的清理或容量限制。以 1844×1240 的 24 位像素计算,单张像素数据约 6.54 MiB,1,000 张约 6.4 GiB;这是容量估算,并非设备磁盘实测。临时打印图长期保留也扩大了照片留存范围。
**建议:** 在确认 SDK 不再读取文件后清理;失败路径也应清理未提交的文件。增加启动时孤儿文件清理、保留时限与容量上限,保护正在使用的任务文件,并在空间不足时明确拒绝新任务。此项依赖第 01 项的可靠任务终态,不能在 `addJob()` 后立即删除。
**验收:** 连续打印及异常重启后目录容量维持在设定上限,待打印文件不被误删。
## 3. 下一轮可靠性与质量优化
### 08|隐私取片走缩略图,图片处理链路不统一(P2)
位置:[PrivacyPrintService.kt:135](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt:135)、[PrivacyPrintService.kt:537](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt:537)、[BatchQueryResponse.kt:29](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/network/model/response/BatchQueryResponse.kt:29)。
隐私取片建立图片映射时选择 `thumbnailLanUrl/thumbnailOssUrl`,但响应模型已有原图字段。之后把 Coil Drawable 再复制成 Bitmap,增加峰值内存;该流程也没有复用普通打印流程的下载容器校验。两条流程的输入质量和校验标准不同。
建议打印优先采用原图,缩略图保留给页面预览;原图缺失时按明确的产品策略报错或允许有提示的降级。统一下载、完整性验证、采样和 Bitmap 所有权,避免未经确认就回收图片缓存持有的对象。用相同订单在两个入口打印,比较有效像素、裁切和成片清晰度,并测量峰值内存。
### 09|50 MiB 下载上限在未知长度响应中生效过晚(P2)
位置:[PrintingViewModel.kt:527](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt:527)、[PrintImageIntegrityValidator.kt:46](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/priter/PrintImageIntegrityValidator.kt:46)。
已实现 Content-Length 检查和解码前大小检查,这是有效改进。但当服务端未提供长度时,`body.bytes()` 会先完整读取响应,之后才检查 50 MiB 上限,无法阻止读取阶段的内存膨胀。捕获 `OutOfMemoryError` 不能代替输入限流。
建议按块读取并累计实际字节数,超过上限立即停止;或流式落临时文件后做边界检查与采样解码。协程取消时同时取消底层 Call。验收使用无 Content-Length、超限和中途断流响应,确认超限读取及时停止且不提交打印。
### 10|错误响应被改写,空数据被强转为 Unit(P2)
位置:[ResponseInterceptor.kt:26](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/network/interceptor/ResponseInterceptor.kt:26)、[ResultHandler.kt:44](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/network/result/ResultHandler.kt:44)。
HTTP 成功但业务码不是 `100000` 时,拦截器将 `data` 强制替换成 `{}`。对于列表、字符串等接口类型,这可能在进入业务错误分支前触发反序列化失败,丢失真实错误信息。另一处 `response.data ?: Unit as T` 则把成功但缺失的数据伪装为任意业务类型,调用方可能发生类型转换异常。
建议保持原始响应结构,在结果层区分业务失败、协议格式错误和传输失败;有返回数据和无返回数据的接口分别建模。补充对象/数组/字符串/null、业务失败和 HTTP 失败的契约测试,确认不会用类型异常覆盖原错误。
### 11|APK 复用仅判断长度,未知长度时仍直接安装(P2)
位置:[VersionUpdateManager.kt:313](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/utils/VersionUpdateManager.kt:313)、[VersionUpdateManager.kt:536](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/utils/VersionUpdateManager.kt:536)。
已有文件与服务器长度相同便复用;无法获取有效长度时也复用本地文件。新下载同样主要检查长度,不能识别等长内容损坏或错误版本缓存。
建议版本元数据提供可信摘要,下载到临时路径后校验内容摘要、预期包名、版本及签名,再原子提交。不要把本地计算出的哈希本身当作真实性证明,必须与可信预期值比较。系统安装校验仍然存在;本项不声称任意错误签名 APK 能覆盖安装。验收覆盖等长损坏、未知长度、旧缓存及错误版本。
### 12|升级迁移可能清除上传任务(P2)
位置:[DatabaseModule.kt:20](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/network/db/DatabaseModule.kt:20)、[AppDatabase.kt:10](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/network/db/AppDatabase.kt:10)。
`app_database` 只注册 `7 → 8` 迁移,并启用了破坏性迁移回退。若设备存在无迁移链覆盖的旧版本数据库,待上传任务会有丢失风险。两个 Room 数据库声明导出 schema,但本次检索构建配置未找到 `room.schemaLocation`。
建议先列出现网数据库版本,再补齐所有仍支持版本的迁移路径,导出并纳入版本管理的 schema,执行保留任务数据的迁移测试。`CloudDatabaseModule` 已有 `1 → 2 → 3` 迁移链,不应将本问题泛化为两个数据库都没有迁移。
### 13|凭证备份与授权状态缺少显式边界(P2)
位置:[MMKVUtils.kt:17](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/utils/MMKVUtils.kt:17)、[AppStoreDataSource.kt:238](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSource.kt:238)、[AndroidManifest.xml:60](/Users/hanqiu/Desktop/KIOSK/app/src/main/AndroidManifest.xml:60)、[App.kt:55](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/App.kt:55)。
设备密钥和 Token 经默认 MMKV 保存,未传入加密密钥;应用允许备份,两个备份规则文件仍是模板,未显式排除敏感数据。`App.onCreate()` 无条件告知地图 SDK 已同意,未关联应用内可核查的授权记录。源码存在隐私弹窗类,但未检索到业务调用。
建议定义凭证的存储、迁移和吊销边界,用受设备密钥保护的加密方案保存敏感字段,明确备份/迁移排除规则。地图初始化条件应关联真实授权或有记录的设备部署流程。另需核对 Manifest 中全局明文访问与各项权限的实际用途,再按公网/局域网需求收敛;不要直接阻断确有需要的局域网照片服务。
验收覆盖首次部署、重启、清除授权、备份还原及设备更换,确认凭证不会意外迁移且 SDK 行为与记录一致。此处审查的是技术状态与数据边界,未做法律合规结论。
### 14|构建入口与质量检查尚未形成稳定门禁(P2)
位置:[gradlew](/Users/hanqiu/Desktop/KIOSK/gradlew)、[ExampleInstrumentedTest.kt:24](/Users/hanqiu/Desktop/KIOSK/app/src/androidTest/java/com/yzx/kiosk/ExampleInstrumentedTest.kt:24)。
本次直接运行 `./gradlew` 失败,原因是 CRLF shebang:`env: sh\r: No such file or directory`。当前文件已经有执行权限,不能再沿用旧报告的“无执行权限”结论。为保留用户已有改动,本次通过 Java 直接运行 Gradle Wrapper 完成验证。
建议 Wrapper 固定为 LF,加入换行规范。Lint 的 4 个错误应逐一处理:3 个 Media3 opt-in 错误;另 1 个通知权限错误来自 Glide 的 `NotificationTarget` 使用分析,需先确认产品是否实际发送通知,再补权限流程或对确实不可达的库路径做有说明的定向处理,避免为消除告警盲目增加权限。
已有 44 个单元测试全部通过,但核心打印终态、回执持久化、WebSocket 停止/重连、迁移链仍缺少对应专项测试。Instrumentation 示例仍断言旧包名 `com.zhifly.follow`,与当前应用包名不符,应修复或移除失效示例。
验收目标:标准 `./gradlew` 命令可运行,Lint 错误清零;增加本报告 P1 场景的故障注入测试和目标机型集成测试。
### 15|云传输多任务模型与单一取消句柄冲突(P2,启用前处理)
位置:[DownloadRepository.kt:57](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/utils/cloud/DownloadRepository.kt:57)、[UploadRepository.kt:57](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/utils/cloud/UploadRepository.kt:57)、[CloudOssUtils.kt:51](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/utils/cloud/CloudOssUtils.kt:51)。
两个 Repository 以 `taskId → Job` 管理多个任务,OSS 封装却各只保存一个 `currentUploadTask/currentDownloadTask`。A、B 同时执行后暂停 A,会取消最后登记的 SDK 任务 B。进度与终态共用 `callbackFlow.trySend()` 且未检查发送结果,缓冲区被进度占满时还有丢失成功/失败事件的风险;进度回调逐次写库也可能放大压力。
**当前可达性限制:** 本次检索主源码只找到两个 Repository 的定义,未找到业务引用。应先确认是否是预留/废弃功能,因此不将其列为当前线上必现故障。普通 `UploadManager` 是另一条传输实现,不能混为同一入口。
若保留此功能,SDK 请求句柄应按任务持有,取消操作绑定明确任务;限制并发并保护任务表;进度可节流或合并,但终态必须可靠送达并入库。验收并行上传/下载时只取消指定任务,模拟缓慢写库仍不丢终态。
### 16|包体、诊断和职责划分(P3)
| 方向 | 当前证据 | 建议及衡量方式 |
| --- | --- | --- |
| 包体 | [构建配置:149](/Users/hanqiu/Desktop/KIOSK/app/build.gradle.kts:149) 关闭 R8/资源压缩,声明多个 ABI;[233 行](/Users/hanqiu/Desktop/KIOSK/app/build.gradle.kts:233) 将 Crashlytics buildtools 作为运行时依赖 | 先分析依赖树与 APK 构成,确认是否有实际运行时代码依赖;按终端 ABI 交付,逐步开启压缩并验证反射/JNI/厂商 SDK。记录每步产物大小,不沿用旧报告的 144 MB 作为现状 |
| 线上诊断 | [LogUtils.kt:15](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/utils/LogUtils.kt:15) 将 error/warn 也限制为 Debug | 发布版本保留脱敏错误事件、订单/任务关联 ID、重试次数和失败阶段,限制日志容量。验收一笔失败订单能定位到下载、硬件或通知阶段,不记录凭证和完整照片 URL |
| 大类职责 | DeviceConfigViewModel 1,574 行、WebSocketService 947 行、VersionUpdateManager 811 行、FaceRecognitionResultViewModel 721 行 | 按设备配置、连接、支付协调、升级下载/安装划分可测试组件;先修业务边界,再拆文件,避免一次性重构全工程 |
| 图片列表刷新 | [FaceRecognitionResultViewModel.kt:235](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt:235) 每次宽高比更新遍历整表;页面已使用稳定图片 ID 作为 key | 先在大量照片场景测滚动帧耗时和重组,再决定批量更新或按项状态。当前没有性能数据,不能断言已有明显卡顿 |
| 启动文件 I/O | [App.kt:66](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/App.kt:66) 在启动线程同步清理人脸残留文件 | 大量残留时可能拖慢启动;测量后迁到 IO,并用初始化屏障或文件代次防止清理新会话文件 |
## 4. 已有改进,应保留
这些内容说明旧报告不能原样套用到当前代码:
- `PrinterService` 已有互斥锁;`PrintImageIntegrityValidator` 已有输入容器、尺寸、采样、BMP 结构校验。应在此基础上补真实终态与下载读取上限。
- 人脸预览解码已有采样、EXIF 处理和最长边 720px 限制,相关实现检查了临时 Bitmap 的身份后再回收。
- 人脸拍摄有专门的文件管理器,并在流程结束/退出以及下次启动清理残留。
- 结果网格通过已加载 Drawable 获取比例,避免单独再次下载图片探测尺寸。
- 人脸购片已有报价代次控制、支付重复处理保护、最近照片请求去重等逻辑,并有专项单元测试。
- 网络下载客户端已避免通用响应拦截器吞入大文件;普通网络日志仅在 Debug 配置启用。
## 5. 本次验证记录
| 项目 | 本次结果 |
| --- | --- |
| 标准 Wrapper 入口 | 失败:CRLF shebang;执行权限当前存在 |
| Debug 单元测试 | **44 项通过,0 失败、0 错误、0 跳过**;11 个测试源码文件 |
| Debug Lint | **失败:4 errors、329 warnings、8 hints** |
| Instrumentation 测试 | 源码共 5 个文件、20 个 `@Test`;本次未运行 |
| 物理打印、USB 热插拔、断网重连 | 本次未做设备实测 |
| Release 构建、签名摘要、APK 大小 | 本次未重新测量 |
本次执行命令(先以标准入口尝试,再绕过 CRLF 启动脚本):
```sh
./gradlew :app:testDebugUnitTest :app:lintDebug --offline --console=plain
java -classpath gradle/wrapper/gradle-wrapper.jar org.gradle.wrapper.GradleWrapperMain :app:testDebugUnitTest :app:lintDebug --offline --console=plain
```
第二条命令已完成单元测试,最终因为 Lint 失败返回非零退出码。运行时使用现有离线依赖缓存,部分编译任务为 UP-TO-DATE;这不是全量 clean build。Gradle 缓存写入限制已通过获准的执行权限解决,不属于工程缺陷。
持久化证据:[构建与检查日志](/Users/hanqiu/Desktop/KIOSK/docs/reviews/2026-09-20/gradle-verification.log)、[单元测试摘要](/Users/hanqiu/Desktop/KIOSK/docs/reviews/2026-09-20/unit-test-summary.md)、[Lint XML](/Users/hanqiu/Desktop/KIOSK/docs/reviews/2026-09-20/lint-results-debug.xml)。
本次 4 个 Lint 错误位置:
| 规则 | 位置 |
| --- | --- |
| NotificationPermission | AndroidManifest.xml;报告说明来源为 Glide NotificationTarget |
| UnsafeOptInUsageError | AppNavHost.kt:94 |
| UnsafeOptInUsageError | PosterScreenWithLiveOrSlideshow.kt:115 |
| UnsafeOptInUsageError | PosterScreenWithLiveOrSlideshow.kt:117 |
## 6. 推荐实施顺序与验收门槛
1. **打印闭环:** 先明确硬件终态和去重协议,完成 01~04,再实现 07 的安全清理;验收以真实出片、库存和服务端订单一致为准。
2. **连接与发布控制:** 修复 05,处理 06 的凭证及既有安装签名迁移;并行修复 Wrapper 与 4 个 Lint 错误。
3. **长期运行保障:** 完成 08~13,补齐断网、磁盘不足、进程重启和数据库升级的故障场景。
4. **结构和性能:** 先确认 15 的业务去留,再执行 16 的拆分、测量与包体优化。
在开始实现前,需要从设备/服务端取得三类信息:打印 SDK 可用的任务终态接口;消息重复投递及授权补打标识;现网 APK 签名、数据库版本与升级范围。这些信息影响具体实现方案,但不影响上述源码问题的成立。
首轮完成标准应是:打印终态可靠、回执可恢复、重复消息不重复出片、主动停止不重连、文件容量受控、发布凭证管理明确,并将对应回归检查加入日常构建。
Vendored Regular → Executable
View File