diff --git a/app/src/main/java/com/yzx/kiosk/network/di/NetworkModule.kt b/app/src/main/java/com/yzx/kiosk/network/di/NetworkModule.kt index f164ee2..18d64dd 100644 --- a/app/src/main/java/com/yzx/kiosk/network/di/NetworkModule.kt +++ b/app/src/main/java/com/yzx/kiosk/network/di/NetworkModule.kt @@ -3,6 +3,7 @@ package com.yzx.kiosk.network.di import com.google.gson.Gson import com.google.gson.GsonBuilder import com.yzx.kiosk.BuildConfig +import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor import com.yzx.kiosk.network.interceptor.RequestInterceptor import com.yzx.kiosk.network.interceptor.ResponseInterceptor import dagger.Module @@ -10,7 +11,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit @@ -41,18 +41,11 @@ object NetworkModule { @Singleton fun provideGson(): Gson = GsonBuilder().create() - @Provides - @Singleton - fun provideLoggingInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor().apply { - level = - if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE - } - private fun buildOkHttpClient( timeout: Long, requestInterceptor: RequestInterceptor, responseInterceptor: ResponseInterceptor, - loggingInterceptor: HttpLoggingInterceptor? + clientName: String, ): OkHttpClient = OkHttpClient.Builder() .connectTimeout(timeout, TimeUnit.SECONDS) .writeTimeout(timeout, TimeUnit.SECONDS) @@ -61,7 +54,9 @@ object NetworkModule { .addInterceptor(requestInterceptor) .addInterceptor(responseInterceptor) .apply { - if (BuildConfig.DEBUG) loggingInterceptor?.let { addInterceptor(it) } + if (BuildConfig.DEBUG) { + addInterceptor(DebugNetworkLoggingInterceptor(clientName)) + } }.build() @Provides @@ -70,12 +65,11 @@ object NetworkModule { fun provideDefaultOkHttpClient( requestInterceptor: RequestInterceptor, responseInterceptor: ResponseInterceptor, - loggingInterceptor: HttpLoggingInterceptor, ): OkHttpClient = buildOkHttpClient( TIMEOUT_SHORT, requestInterceptor, responseInterceptor, - loggingInterceptor, + "api", ) @Provides @@ -84,7 +78,12 @@ object NetworkModule { fun provideUploadOkHttpClient( requestInterceptor: RequestInterceptor, responseInterceptor: ResponseInterceptor, - ): OkHttpClient = buildOkHttpClient(TIMEOUT_LONG, requestInterceptor, responseInterceptor, null) + ): OkHttpClient = buildOkHttpClient( + TIMEOUT_LONG, + requestInterceptor, + responseInterceptor, + "upload", + ) @Provides @Singleton @@ -98,6 +97,11 @@ object NetworkModule { .callTimeout(TIMEOUT_LONG, TimeUnit.SECONDS) .addInterceptor(requestInterceptor) // 注意:不添加 ResponseInterceptor,避免将大文件加载到内存 + .apply { + if (BuildConfig.DEBUG) { + addInterceptor(DebugNetworkLoggingInterceptor("download")) + } + } .build() private fun buildRetrofit( @@ -127,4 +131,4 @@ object NetworkModule { gson: Gson, @Named(BASE_URL) baseUrl: String ): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/yzx/kiosk/network/interceptor/DebugNetworkLoggingInterceptor.kt b/app/src/main/java/com/yzx/kiosk/network/interceptor/DebugNetworkLoggingInterceptor.kt new file mode 100644 index 0000000..56fa9ef --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/interceptor/DebugNetworkLoggingInterceptor.kt @@ -0,0 +1,202 @@ +package com.yzx.kiosk.network.interceptor + +import android.util.Log +import com.yzx.kiosk.BuildConfig +import okhttp3.Headers +import okhttp3.Interceptor +import okhttp3.MediaType +import okhttp3.Request +import okhttp3.Response +import okio.Buffer +import java.nio.charset.StandardCharsets +import java.util.Locale +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong + +/** + * Debug-only HTTP logger used by every application-owned OkHttpClient. + * + * Text bodies are capped to avoid flooding Logcat or buffering large payloads. Binary, + * multipart, one-shot and duplex bodies are represented by metadata only. Response bodies are + * inspected with [Response.peekBody], so logging never consumes the body used by business code. + */ +class DebugNetworkLoggingInterceptor( + private val clientName: String = "api", +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + if (!BuildConfig.DEBUG) return chain.proceed(chain.request()) + + val request = chain.request() + val requestId = REQUEST_SEQUENCE.incrementAndGet() + val startedAt = System.nanoTime() + + log("┌─ #$requestId [$clientName] --> ${request.method} ${redactedUrl(request)}") + logHeaders(requestId, "request", request.headers) + logRequestBody(requestId, request) + + val response = try { + chain.proceed(request) + } catch (error: Throwable) { + val durationMs = elapsedMillis(startedAt) + log("└─ #$requestId [$clientName] <-- HTTP FAILED (${durationMs}ms): ${error.javaClass.simpleName}: ${error.message}") + throw error + } + + val durationMs = elapsedMillis(startedAt) + log("├─ #$requestId [$clientName] <-- ${response.code} ${response.message} (${durationMs}ms) ${redactedUrl(request)}") + logHeaders(requestId, "response", response.headers) + logResponseBody(requestId, response) + log("└─ #$requestId [$clientName] END HTTP") + return response + } + + private fun logRequestBody(requestId: Long, request: Request) { + val body = request.body ?: run { + log("#$requestId request body: ") + return + } + val contentType = body.contentType() + val contentLength = runCatching { body.contentLength() }.getOrDefault(-1L) + val description = describeBody(contentType, contentLength) + + if (body.isDuplex() || body.isOneShot()) { + log("#$requestId request body: $description ") + return + } + if (!isText(contentType) || isEncoded(request.headers) || contentLength > MAX_BODY_BYTES) { + log("#$requestId request body: $description ") + return + } + + val bodyText = runCatching { + Buffer().use { buffer -> + body.writeTo(buffer) + if (buffer.size > MAX_BODY_BYTES) { + "$description " + } else { + val charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8 + "$description\n${buffer.readString(charset)}" + } + } + }.getOrElse { "$description " } + log("#$requestId request body: $bodyText") + } + + private fun logResponseBody(requestId: Long, response: Response) { + val body = response.body ?: run { + log("#$requestId response body: ") + return + } + val contentType = body.contentType() + val contentLength = body.contentLength() + val description = describeBody(contentType, contentLength) + + if (!isText(contentType) || isEncoded(response.headers)) { + log("#$requestId response body: $description ") + return + } + + val bodyText = runCatching { + val preview = response.peekBody(MAX_BODY_BYTES) + val text = preview.string() + val truncated = contentLength > MAX_BODY_BYTES || + (contentLength == -1L && preview.contentLength() >= MAX_BODY_BYTES) + buildString { + append(description).append('\n').append(text) + if (truncated) append("\n") + } + }.getOrElse { "$description " } + log("#$requestId response body: $bodyText") + } + + private fun logHeaders(requestId: Long, direction: String, headers: Headers) { + if (headers.size == 0) { + log("#$requestId $direction headers: ") + return + } + val text = buildString { + append("#$requestId $direction headers:") + for (index in 0 until headers.size) { + val name = headers.name(index) + val value = if (isSensitiveHeader(name)) REDACTED else headers.value(index) + append('\n').append(name).append(": ").append(value) + } + } + log(text) + } + + private fun redactedUrl(request: Request): String { + val url = request.url + val builder = url.newBuilder() + url.queryParameterNames.forEach { name -> + if (isSensitiveName(name)) builder.setQueryParameter(name, REDACTED) + } + return builder.build().toString() + } + + private fun isEncoded(headers: Headers): Boolean { + val encoding = headers["Content-Encoding"] ?: return false + return !encoding.equals("identity", ignoreCase = true) + } + + private fun isText(contentType: MediaType?): Boolean { + if (contentType == null) return false + val type = contentType.type.lowercase(Locale.US) + val subtype = contentType.subtype.lowercase(Locale.US) + if (type == "text") return true + return subtype.contains("json") || + subtype.contains("xml") || + subtype.contains("html") || + subtype.contains("x-www-form-urlencoded") || + subtype.contains("javascript") + } + + private fun describeBody(contentType: MediaType?, contentLength: Long): String { + val length = if (contentLength >= 0) "$contentLength bytes" else "unknown length" + return "${contentType ?: "unknown content-type"}, $length" + } + + private fun isSensitiveHeader(name: String): Boolean = SENSITIVE_NAMES.contains(name.lowercase(Locale.US)) + + private fun isSensitiveName(name: String): Boolean = SENSITIVE_NAMES.contains(name.lowercase(Locale.US)) + + private fun log(message: String) { + if (!BuildConfig.DEBUG) return + if (message.isEmpty()) { + Log.d(TAG, "") + return + } + var start = 0 + while (start < message.length) { + val end = minOf(start + LOGCAT_CHUNK_SIZE, message.length) + Log.d(TAG, message.substring(start, end)) + start = end + } + } + + private fun elapsedMillis(startedAt: Long): Long = + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt) + + companion object { + const val TAG = "KIOSK-NET" + + private const val MAX_BODY_BYTES = 64L * 1024L + private const val LOGCAT_CHUNK_SIZE = 3_500 + private const val REDACTED = "██" + private val REQUEST_SEQUENCE = AtomicLong(0) + private val SENSITIVE_NAMES = setOf( + "authorization", + "token", + "access_token", + "api_key", + "apikey", + "cookie", + "set-cookie", + "sn", + "device_sn", + "password", + "secret", + ) + } +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt index dd56d9b..d53bf8f 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt @@ -18,6 +18,7 @@ import com.yzx.kiosk.network.model.response.FaceSearchResponse import com.yzx.kiosk.network.service.FaceSearchService import com.yzx.kiosk.App import com.yzx.kiosk.BuildConfig +import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor import com.yzx.kiosk.navigation.AppNavigator import com.yzx.kiosk.navigation.routes.AppRoutes import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes @@ -38,7 +39,6 @@ import okhttp3.OkHttpClient import okhttp3.RequestBody import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.io.File @@ -260,23 +260,20 @@ class FaceRecognitionViewModel @Inject constructor( LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========") LogUtils.d(TAG, "URL: $requestUrl") LogUtils.d(TAG, "Headers:") - LogUtils.d(TAG, "Authorization: $authorization") + LogUtils.d(TAG, "Authorization: ") LogUtils.d(TAG, "Parameters:") LogUtils.d(TAG, "image: ${imageFile.name} (${imageFile.length()} bytes)") LogUtils.d(TAG, "threshold: $thresholdValue") LogUtils.d(TAG, "index_date: $indexDate") LogUtils.d(TAG, "==========================================") - // 创建日志拦截器 - val loggingInterceptor = HttpLoggingInterceptor { message -> - LogUtils.d(TAG, message) - }.apply { - level = HttpLoggingInterceptor.Level.BODY - } - - // 创建OkHttpClient,添加日志拦截器 + // Debug 环境记录请求与响应;图片等大文件只记录元数据,不读取文件内容 val client = OkHttpClient.Builder() - .addInterceptor(loggingInterceptor) + .apply { + if (BuildConfig.DEBUG) { + addInterceptor(DebugNetworkLoggingInterceptor("face-search")) + } + } .build() // 创建Retrofit实例 diff --git a/app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt b/app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt index 1dd9938..47329e2 100644 --- a/app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt +++ b/app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt @@ -7,7 +7,9 @@ import coil.request.ImageRequest import coil.size.Size import com.google.gson.Gson import com.yzx.kiosk.App +import com.yzx.kiosk.BuildConfig import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor import com.yzx.kiosk.network.model.request.PrintCompleteRequest import com.yzx.kiosk.network.model.request.PrintNotifyRequest import com.yzx.kiosk.network.model.response.BatchQueryResponse @@ -65,6 +67,11 @@ class PrivacyPrintService @Inject constructor( .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) + .apply { + if (BuildConfig.DEBUG) { + addInterceptor(DebugNetworkLoggingInterceptor("privacy-print")) + } + } .build() // 缓存 AppState 实例,避免频繁调用 Lazy.get() diff --git a/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt b/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt index 40e9b2a..cd87c51 100644 --- a/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt +++ b/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt @@ -4,6 +4,7 @@ import com.google.gson.Gson import com.google.gson.JsonObject import com.yzx.kiosk.BuildConfig import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor import com.yzx.kiosk.network.repository.NetWorkRepository import com.yzx.kiosk.ui.poster.ScenicLivePosterController import com.yzx.kiosk.priter.PrintStatusManager @@ -100,6 +101,11 @@ class WebSocketService @Inject constructor( .readTimeout(10, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .pingInterval(30, TimeUnit.SECONDS) // 自动 ping + .apply { + if (BuildConfig.DEBUG) { + addInterceptor(DebugNetworkLoggingInterceptor("websocket")) + } + } .build() } } @@ -971,4 +977,3 @@ sealed class UploadPhotoEvent { val imageIds: List ) : UploadPhotoEvent() } - diff --git a/docs/工程业务与技术导读.md b/docs/工程业务与技术导读.md new file mode 100644 index 0000000..38592b0 --- /dev/null +++ b/docs/工程业务与技术导读.md @@ -0,0 +1,493 @@ +# KIOSK 工程业务与技术导读 + +> 面向第一次接手工程的开发者。本文基于 2026-08-04 的代码状态整理,目标是先建立完整的业务和技术心智模型,再进入具体模块开发。 + +## 1. 一句话理解这个工程 + +KIOSK 是部署在景区或线下门店 Android 自助终端上的照片售卖与打印客户端:游客可以上传手机照片,或通过人脸识别找到景区相机拍摄的照片,完成选片和支付后,由终端连接 DNP 照片打印机出片。 + +它同时还是一台长期在线的设备,因此还承担: + +- 设备身份认证和配置同步 +- WebSocket 实时消息接收与心跳上报 +- 微信扫码隐私取片任务 +- 打印机、纸张余量和任务状态管理 +- 待机海报、视频、直播和语音播放 +- 运营素材上传 +- APK 在线升级和开机自启动 + +应用名称是“自助照片打印”,Application ID 为 `com.yzx.kiosk`,最低支持 Android 8.0(API 26)。 + +## 2. 业务参与方 + +理解下面几个参与方后,工程中的接口和状态会清晰很多。 + +| 参与方 | 职责 | +| --- | --- | +| 游客 | 在终端上选业务、拍照识别人脸、选片、扫码支付和取走照片 | +| 手机端/微信页面 | 扫终端二维码、上传照片、支付或发起隐私取片 | +| KIOSK Android 客户端 | 展示业务页面,维护设备状态,接收订单并驱动打印机 | +| Oscar 业务后端 | 下发设备配置、二维码、价格、订单、Socket Token、版本信息等 | +| 照片盒子/人脸服务 | 保存景区相机照片,提供公网或局域网人脸检索及图片地址 | +| OSS | 保存照片、海报、视频、音频和升级包等文件 | +| DNP 打印机 | 实际输出照片,当前主要适配 RX1 和 QW410 | +| 运维人员 | 配置设备密钥、局域网模式、纸张数、素材、打印机和应用版本 | + +整体关系如下: + +```mermaid +flowchart LR + Visitor["游客"] --> Kiosk["Android KIOSK"] + Phone["手机端 / 微信"] <-->|"上传、支付、取片"| Backend["Oscar 业务后端"] + Kiosk <-->|"HTTPS API"| Backend + Kiosk <-->|"WebSocket 消息与心跳"| Backend + Kiosk <-->|"人脸检索、照片读取"| FaceBox["照片盒子 / 人脸服务"] + Backend <--> OSS["阿里云 OSS"] + Kiosk -->|"下载或上传素材"| OSS + Kiosk -->|"DNP SDK 打印任务"| Printer["RX1 / QW410 打印机"] + Operator["运维人员"] --> Kiosk +``` + +## 3. 用户能看到的主要业务 + +### 3.1 首页 + +首页是所有业务的入口,主要展示: + +- 人脸识别照片/打卡点照片打印入口 +- 上传手机照片打印入口 +- 微信扫码隐私取片二维码 +- 首页宣传视频 +- 当前打印任务状态 +- 剩余打印纸张数和客服电话 + +首页双击特定区域会弹出管理密码,校验后进入设置页。首页 60 秒无操作,且当前没有打印任务时,会进入待机海报或直播层;用户触摸后返回首页。 + +主要代码: + +- [`HomeScreen.kt`](../app/src/main/java/com/yzx/kiosk/ui/home/view/HomeScreen.kt) +- [`HomeViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/home/viewmodel/HomeViewModel.kt) +- [`InactivityManager.kt`](../app/src/main/java/com/yzx/kiosk/utils/InactivityManager.kt) + +### 3.2 手机照片上传打印 + +这是 `capture type = 1` 的业务。 + +```mermaid +sequenceDiagram + actor User as 游客 + participant K as KIOSK + participant API as 业务后端 + participant WS as WebSocket + participant P as DNP 打印机 + + User->>K: 进入“上传手机照片打印” + K->>API: 获取上传二维码和打印服务信息 + K-->>User: 展示二维码、价格和规格 + User->>API: 手机扫码并上传照片 + WS-->>K: code=1 扫码成功 + WS-->>K: code=2 下发 file_map 照片列表 + K-->>User: 展示照片并选择 + K->>API: verify-result 计算单价和总价 + K->>API: get-pay-url 获取支付二维码 + User->>API: 手机支付 + WS-->>K: code=5 支付成功及订单信息 + K-->>User: 支付成功,确认要打印的照片 + K->>P: 逐张下载、处理并打印 + K->>API: print-notify 上报每张结果 + K->>API: print-complete 上报订单完成 +``` + +对应页面链路: + +```text +首页 + -> 上传照片二维码页 UploadPhotoScreen + -> 照片选择页 PhotoSelectScreen + -> 支付成功页 PaySuccessScreen + -> 打印页 PrintingScreen + -> 自动返回首页 +``` + +核心类: + +- [`UploadPhotoViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/UploadPhotoViewModel.kt):加载上传二维码,监听扫码和文件列表事件 +- [`PhotoSelectViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PhotoSelectViewModel.kt):选片、计价、获取支付二维码 +- [`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):逐张下载、打印、记录并上报结果 + +服务端通过 `file_map` 同时下发 OSS 和局域网地址。客户端根据设置中的“使用 LAN”开关选择 `*_lan_url` 或 `*_oss_url`。 + +### 3.3 人脸识别照片打印 + +这是 `capture type = 2` 的业务,适用于景区相机、无人机或打卡点预先拍摄的照片。 + +业务过程: + +1. 游客进入人脸识别页。 +2. CameraX 打开前置相机,支持 5 秒倒计时或手动拍照。 +3. 客户端把照片上传到照片盒子的 `/{sn}/api/search` 接口。 +4. 服务根据人脸相似度返回当天匹配的照片。 +5. 终端展示缩略图,游客选择照片。 +6. 客户端请求后端计算价格并生成支付二维码。 +7. WebSocket 收到支付成功事件后进入支付成功页和打印页。 + +主要代码: + +- [`FaceRecognitionScreen.kt`](../app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionScreen.kt):相机页面 +- [`FaceRecognitionViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt):拍照、方向修正和人脸检索 +- [`FaceRecognitionResultViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt):结果展示、选片、计价和支付 +- [`FaceSearchService.kt`](../app/src/main/java/com/yzx/kiosk/network/service/FaceSearchService.kt):人脸搜索 Retrofit 接口 + +人脸服务地址、盒子 SN 和 Token 来自设备配置。设置为 LAN 模式时走局域网地址,否则走公网地址。 + +### 3.4 微信扫码隐私取片 + +隐私取片不是通过当前页面一步步操作,而是后台 WebSocket 直接下发的打印任务。 + +处理过程: + +1. 首页显示服务端配置的微信隐私取片二维码。 +2. 用户在微信侧完成选片或确认。 +3. WebSocket 收到 `code = 4` 消息。 +4. `PrintQueueManager` 按订单去重并排队。 +5. `PrivacyPrintService` 依次下载照片并调用打印机。 +6. 首页顶部显示“手机尾号 xxxx 用户正在打印/打印完成”。 +7. 客户端上报单张结果和订单完成状态,并更新纸张余量。 + +主要代码: + +- [`WebSocketService.kt`](../app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt) +- [`PrintQueueManager.kt`](../app/src/main/java/com/yzx/kiosk/websocket/PrintQueueManager.kt) +- [`PrivacyPrintService.kt`](../app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt) +- [`PrivacyPrintMessage.kt`](../app/src/main/java/com/yzx/kiosk/websocket/model/PrivacyPrintMessage.kt) + +这条链路允许游客在手机端操作打印时,终端屏幕继续服务其他用户,因此打印状态通过全局状态栏展示,而不是强制占用前台页面。 + +### 3.5 打印机处理 + +打印能力封装在 [`PrinterService.kt`](../app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt) 中,底层使用 DNP 本地 AAR/JNI SDK。 + +当前处理步骤: + +1. 枚举打印机端口并保存打印机 ID。 +2. 根据 ID 区分 RX1 和 QW410。 +3. 按打印机方向旋转原图。 +4. 保持原比例缩放,居中绘制到白色背景画布。 +5. 保存为 300 DPI BMP 文件。 +6. 创建 `PrintJob` 并加入 DNP `PrintQueue`。 +7. 更新打印状态、打印记录和纸张余量。 + +尺寸配置位于 [`PRINTSIZE.kt`](../app/src/main/java/com/yzx/kiosk/priter/PRINTSIZE.kt): + +| 打印机 | 画布尺寸 | 方向 | +| --- | --- | --- | +| DNP RX1 | 1840 × 1240 | 横向 | +| DNP QW410 | 1266 × 1836 | 纵向 | + +工程里的打印包目录名是历史拼写 `priter`,并不是文档笔误。 + +### 3.6 待机广告、直播和语音 + +设备配置接口会下发海报、背景音乐、首页视频及直播信息。 + +- 图片/视频海报使用 Compose + Media3 展示 +- 首页底部视频通过 ExoPlayer 循环播放 +- 景区直播通过 RTMP 地址播放 +- WebSocket 绑定成功后发送 `type = 304` 订阅直播变化 +- 本地页面引导语音由 `LocalAudioPlayService` 管理 +- 网络 BGM 和其他音频由 `AudioPlayService` 管理 +- 打印通知还可通过阿里云语音合成播报 + +主要代码: + +- [`PosterScreenWithLiveOrSlideshow.kt`](../app/src/main/java/com/yzx/kiosk/ui/poster/view/PosterScreenWithLiveOrSlideshow.kt) +- [`ScenicLivePosterController.kt`](../app/src/main/java/com/yzx/kiosk/ui/poster/ScenicLivePosterController.kt) +- [`AudioPlayService.kt`](../app/src/main/java/com/yzx/kiosk/audio/AudioPlayService.kt) +- [`LocalAudioPlayService.kt`](../app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt) + +### 3.7 设备管理和运营配置 + +设置页用于现场运维,主要包括: + +- 切换公网/局域网照片地址 +- 设置剩余打印纸张数 +- 打开设备配置页和打印机管理页 +- 查看帮助中心、关于我们和协议页面 +- 手动检查应用更新 + +设备配置页还可以维护: + +- 设备密钥 +- 景区和客服电话 +- 隐私取片二维码开关 +- 首页直播和新版首页开关 +- 最多 5 个已选海报 +- 背景音乐 +- 上传自定义图片、视频和音频素材 + +运营配置既会调用 `/api/oscar/config/set` 保存到后端,也会同步一部分到本地 MMKV,供首页立即读取。 + +主要代码: + +- [`SettingViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/setting/viewmodel/SettingViewModel.kt) +- [`DeviceConfigViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/device/viewmodel/DeviceConfigViewModel.kt) +- [`PrinterManageViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/printer/viewmodel/PrinterManageViewModel.kt) + +### 3.8 在线升级与设备自启动 + +`VersionUpdateManager` 请求 `type = 8` 的最新 Android 终端版本,下载 APK 后通过 `FileProvider` 拉起系统安装界面。Manifest 同时注册了开机和包更新广播,目标是让自助终端重启或升级后恢复运行。 + +主要代码: + +- [`VersionUpdateManager.kt`](../app/src/main/java/com/yzx/kiosk/utils/VersionUpdateManager.kt) +- [`BootReceiver.kt`](../app/src/main/java/com/yzx/kiosk/receiver/BootReceiver.kt) +- [`AndroidManifest.xml`](../app/src/main/AndroidManifest.xml) + +## 4. 应用启动时发生什么 + +```text +应用进程启动,App.onCreate 完成全局初始化 + -> 系统或桌面启动 SplashActivity + -> 等待约 1.5 秒 + -> 进入 MainActivity + -> 初始化无操作检测并注册 WebSocket 监听器 + -> MainActivity 获取 Socket Token + -> WebSocket 连接并发送 type=1000 绑定设备 + -> Compose 创建 AppNavHost 并进入首页 + -> HomeViewModel 拉取设备配置 + -> 每 5 秒发送 type=1001 心跳 + -> 首页开始展示入口、二维码、视频和纸张信息 + -> 无操作 60 秒后展示待机海报或直播 +``` + +Application 级初始化在 [`App.kt`](../app/src/main/java/com/yzx/kiosk/App.kt) 中完成,包括 Hilt、MMKV、日志、Toast、Coil 缓存和全局状态初始化。DNP SDK 延迟到第一次使用打印机时初始化。 + +## 5. WebSocket 协议在客户端中的作用 + +WebSocket 是业务闭环的关键,不只是普通通知。当前客户端使用的主要消息如下: + +| 方向 | 类型或 code | 含义 | +| --- | --- | --- | +| 客户端 -> 服务端 | `type = 1000` | 使用 Socket Token 绑定设备 | +| 客户端 -> 服务端 | `type = 1001` | 心跳,上报设备、打印机状态和剩余纸张 | +| 客户端 -> 服务端 | `type = 304` | 订阅景区直播状态 | +| 服务端 -> 客户端 | `code = 1` | 手机扫码成功 | +| 服务端 -> 客户端 | `code = 2` | 上传照片列表到达 | +| 服务端 -> 客户端 | `code = 4` | 隐私取片打印任务 | +| 服务端 -> 客户端 | `code = 5` | 支付成功,包含订单和照片信息 | +| 服务端 -> 客户端 | `code = 100000` | 绑定、心跳或直播动作成功 | +| 服务端 -> 客户端 | `code = 100099` | Token 失效,需要刷新并重新绑定 | + +`WebSocketService` 把上传/支付类消息转换为 `SharedFlow`,页面 ViewModel 各自订阅;隐私打印则直接进入后台打印队列。 + +## 6. 网络认证和环境 + +普通 HTTP 请求由 [`RequestInterceptor.kt`](../app/src/main/java/com/yzx/kiosk/network/interceptor/RequestInterceptor.kt) 添加: + +```text +sn = 设备 SN +timestamp = 当前秒级时间戳 +token = MD5(sn + deviceSecret + timestamp) +``` + +设备密钥需要先在设备配置页录入。没有密钥时客户端不会生成 `token` 请求头,首页会提示先配置设备密钥。 + +构建环境由 `BuildConfig` 区分: + +| 构建类型 | HTTP API | WebSocket | +| --- | --- | --- | +| Debug | `api-test.zhifly.cn` | 测试 WSS | +| Release | `api.zhifly.cn` | 正式 WSS | + +配置位置为 [`app/build.gradle.kts`](../app/build.gradle.kts),网络组件装配位于 [`NetworkModule.kt`](../app/src/main/java/com/yzx/kiosk/network/di/NetworkModule.kt)。 + +## 7. 工程采用的技术 + +### 7.1 语言、构建和 UI + +| 技术 | 用途 | +| --- | --- | +| Kotlin 2.1.10 | 主要开发语言,JVM Target 11 | +| Gradle 8.11.1 + AGP 8.10.0 | Android 构建系统 | +| Jetpack Compose + Material 3 | 主要页面 UI | +| ViewBinding + XML | Splash 等少量传统页面 | +| Navigation Compose | 页面路由和回退栈 | +| StateFlow / SharedFlow | 页面状态、全局状态和实时事件 | +| Coroutines | 网络、打印、上传下载和倒计时等异步任务 | + +整体采用接近单 Activity + MVVM 的结构: + +```mermaid +flowchart TD + UI["Compose Screen"] --> VM["Hilt ViewModel"] + VM --> Repo["Repository / Manager"] + Repo --> Api["Retrofit Service"] + Repo --> Local["Room / MMKV"] + VM --> Device["打印机、相机、播放器"] + WS["WebSocketService"] --> Flow["SharedFlow / StateFlow"] + Flow --> VM + VM --> Nav["AppNavigator"] + Nav --> UI +``` + +### 7.2 依赖注入和架构组件 + +- Hilt:Application、Activity、ViewModel、Repository 和 Service 的依赖注入 +- KSP:Hilt、Room 和 Glide 等代码生成 +- Lifecycle ViewModel:页面状态和协程生命周期 +- 自定义 `AppNavigator`:通过 SharedFlow 解耦 ViewModel 与 NavController +- `BaseViewModel`:统一导航、Loading、网络结果和登录失效处理 + +### 7.3 网络与实时通信 + +- Retrofit + Gson:业务 REST API +- OkHttp:HTTP、APK 下载和 WebSocket +- 自定义请求拦截器:设备 SN、时间戳和签名 Token +- 自定义响应拦截器与结果层:统一解析业务 code +- WebSocket:扫码、照片列表、支付成功、隐私打印、直播和设备心跳 + +### 7.4 数据存储 + +| 存储 | 保存内容 | +| --- | --- | +| MMKV | 设备密钥、设备配置、Socket Token、盒子地址、纸张数、打印记录和开关状态 | +| Room `AppDatabase` | 运营素材上传任务及进度 | +| Room `CloudDatabase` | OSS 上传、下载任务 | +| 应用私有文件目录 | 处理后的打印 BMP、缓存和下载文件 | +| Coil 磁盘缓存 | 网络图片缓存,当前限制约 50 MB | + +MMKV 的统一访问入口是 [`AppStoreDataSource.kt`](../app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSource.kt),全局响应式状态集中在 [`AppState.kt`](../app/src/main/java/com/yzx/kiosk/datastore/AppState.kt)。 + +### 7.5 图片、相机和扫码 + +- CameraX:人脸识别页拍照 +- Coil / Glide:网络图片加载 +- uCrop:照片裁剪 +- PictureSelector:本地运营素材选择 +- ML Kit Barcode Scanning + ZXing:扫码识别和二维码生成 +- Android Bitmap/Canvas:打印前旋转、缩放和白底合成 + +### 7.6 音视频与云能力 + +- AndroidX Media3 / ExoPlayer:视频、音频和 RTMP 直播 +- 阿里云 OSS SDK:素材上传下载 +- 阿里云语音 SDK:状态语音合成 +- 高德地图 SDK:定位相关能力 +- DNP PhotoPrint SDK:照片打印机控制 + +本地闭源 SDK 位于 `app/libs/`,这是工程能否完整构建和连接硬件的重要前提。 + +## 8. 代码目录怎么理解 + +```text +app/src/main/java/com/yzx/kiosk/ +├── App.kt # Application 初始化 +├── SplashActivity.kt # 启动页 +├── MainActivity.kt # 单 Activity 容器、Socket 和待机层 +├── audio/ # 在线/本地音频播放 +├── base/ # ViewModel 基类 +├── component/ # 通用 Compose 组件和全局 UI +├── datastore/ # MMKV 封装和全局状态 +├── navigation/ # 路由、导航事件和 NavHost +├── network/ +│ ├── db/ # 上传任务 Room 数据库 +│ ├── di/ # Hilt 网络与协程模块 +│ ├── interceptor/ # 请求签名和响应处理 +│ ├── manager/ # 素材上传任务调度 +│ ├── model/ # 请求、响应和实体 +│ ├── repository/ # 数据仓库 +│ └── service/ # Retrofit 接口 +├── priter/ # DNP 打印服务和打印状态 +├── receiver/ # 开机/更新广播 +├── ui/ +│ ├── device/ # 设备和运营素材配置 +│ ├── face/ # 人脸识别业务 +│ ├── home/ # 首页 +│ ├── poster/ # 待机海报和直播 +│ ├── printer/ # 打印机管理 +│ ├── setting/ # 设置、协议和帮助 +│ └── upload/ # 上传、选片、支付、打印 +├── utils/ # 更新、日志、二维码、超时等工具 +│ └── cloud/ # OSS 上传下载及第二套 Room 数据库 +└── websocket/ # 长连接、实时事件和隐私打印队列 +``` + +## 9. 建议的代码阅读顺序 + +第一次接手时,不建议从工具类或所有依赖开始看。按下面顺序更容易建立上下文: + +1. [`README.md`](../README.md):先把工程跑起来,理解环境和硬件要求。 +2. [`AppRoutes.kt`](../app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt) 和 [`AppNavHost.kt`](../app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt):知道有哪些页面和用户动线。 +3. [`HomeScreen.kt`](../app/src/main/java/com/yzx/kiosk/ui/home/view/HomeScreen.kt) 和 [`HomeViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/home/viewmodel/HomeViewModel.kt):理解首页配置如何驱动业务入口。 +4. `ui/upload/viewmodel/`:完整看一遍上传、选片、支付、打印主链路。 +5. `ui/face/viewmodel/`:理解第二条照片来源链路。 +6. [`WebSocketService.kt`](../app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt):理解页面为什么会因服务端消息自动跳转。 +7. [`PrinterService.kt`](../app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt) 和 [`PrivacyPrintService.kt`](../app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt):理解硬件和后台任务。 +8. [`NetworkService.kt`](../app/src/main/java/com/yzx/kiosk/network/service/NetworkService.kt) 与 [`AppStoreDataSource.kt`](../app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSource.kt):梳理接口和持久化字段。 +9. `ui/device/`、`utils/cloud/` 和 `VersionUpdateManager`:最后看运营配置、素材和升级能力。 +10. [`工程代码审查报告.md`](工程代码审查报告.md):了解当前缺陷和发布风险。 + +## 10. 接手时最容易混淆的几个概念 + +### capture type + +- `1`:用户从手机上传的照片 +- `2`:通过人脸搜索得到的景区照片 + +它会贯穿计价、支付、打印通知和订单完成接口。 + +### file_map + +一张照片不只有一个 URL,而是同时包含图片 ID、OSS 原图/缩略图地址和 LAN 原图/缩略图地址。打印必须保留图片 ID,服务端才能正确记录每张照片的状态。 + +### 设备 SN、设备密钥和 Socket Token + +- 设备 SN:当前主要取 Android ID,标识终端 +- 设备密钥:运维录入,用于普通 HTTP 请求签名 +- Socket Token:后端接口动态获取,用于 WebSocket 绑定 + +这三个值用途不同,排查鉴权问题时不要混在一起。 + +### 公网模式和 LAN 模式 + +切换的是照片盒子服务和照片 URL 的访问方式,不是 Debug/Release 环境切换。Debug/Release 由构建类型决定,LAN/公网由设备设置决定。 + +### 两类打印 + +- 前台打印:游客在终端完成上传/人脸业务后进入 `PrintingScreen` +- 后台隐私打印:WebSocket `code = 4` 直接进入 `PrivacyPrintService` + +两条链路最终都调用 `PrinterService`,但任务队列、UI 展示和状态上报入口不同。 + +## 11. 接手后的优先验证清单 + +在开始改业务前,建议用测试环境完成以下闭环: + +1. 配置设备密钥,确认设备配置接口成功。 +2. 确认 WebSocket 绑定成功且心跳包含正确纸张和打印机状态。 +3. 手机扫码上传一张照片,走完选片、计价和支付事件。 +4. 使用相机完成一次人脸搜索,分别测试公网和 LAN 地址。 +5. 连接 RX1 或 QW410,各打印一张横图和竖图。 +6. 验证单张 `print-notify` 和最终 `print-complete` 到达服务端。 +7. 发送一条隐私打印任务,确认不会阻断前台用户操作。 +8. 验证海报、视频、直播、BGM 和无操作返回逻辑。 +9. 验证重启后开机自启动、配置恢复和 Socket 重连。 +10. 在准备发布前阅读代码审查报告并处理签名、隐私、打印结果和并发问题。 + +## 12. 当前需要特别注意的技术债 + +本文重点是业务和技术导读,不展开缺陷细节;但以下问题会直接影响你对现有实现的判断: + +- Release 当前实际使用 Debug 签名,且签名信息直接写在 Gradle 配置中。 +- 打印任务加入 SDK 队列后不等于物理打印成功,当前成功判定不够可靠。 +- 部分 Token、设备密钥和业务配置保存在未加密 MMKV 中。 +- WebSocket、打印通知及上传下载存在并发和恢复方面的风险。 +- APK 更新缺少足够的完整性与签名校验。 +- 自动化测试覆盖少,Lint 当前仍有待处理项。 + +具体证据、文件位置和修复建议见 [`工程代码审查报告.md`](工程代码审查报告.md)。 + +--- + +如果要修改某个业务,优先从对应页面的 ViewModel 顺着 Repository、WebSocket 或 PrinterService 向下追踪;如果问题表现为“页面没有自动跳转”,通常还要同时检查 WebSocket 消息是否到达以及 `SharedFlow` 订阅是否仍处于活跃生命周期。