Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1000488c04 | ||
|
|
342655cbb5 | ||
|
|
92dbd59c23 |
@@ -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。
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.yzx.kiosk.network.di
|
|||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.GsonBuilder
|
import com.google.gson.GsonBuilder
|
||||||
import com.yzx.kiosk.BuildConfig
|
import com.yzx.kiosk.BuildConfig
|
||||||
|
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
|
||||||
import com.yzx.kiosk.network.interceptor.RequestInterceptor
|
import com.yzx.kiosk.network.interceptor.RequestInterceptor
|
||||||
import com.yzx.kiosk.network.interceptor.ResponseInterceptor
|
import com.yzx.kiosk.network.interceptor.ResponseInterceptor
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
@@ -10,7 +11,6 @@ import dagger.Provides
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -41,18 +41,11 @@ object NetworkModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideGson(): Gson = GsonBuilder().create()
|
fun provideGson(): Gson = GsonBuilder().create()
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideLoggingInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor().apply {
|
|
||||||
level =
|
|
||||||
if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildOkHttpClient(
|
private fun buildOkHttpClient(
|
||||||
timeout: Long,
|
timeout: Long,
|
||||||
requestInterceptor: RequestInterceptor,
|
requestInterceptor: RequestInterceptor,
|
||||||
responseInterceptor: ResponseInterceptor,
|
responseInterceptor: ResponseInterceptor,
|
||||||
loggingInterceptor: HttpLoggingInterceptor?
|
clientName: String,
|
||||||
): OkHttpClient = OkHttpClient.Builder()
|
): OkHttpClient = OkHttpClient.Builder()
|
||||||
.connectTimeout(timeout, TimeUnit.SECONDS)
|
.connectTimeout(timeout, TimeUnit.SECONDS)
|
||||||
.writeTimeout(timeout, TimeUnit.SECONDS)
|
.writeTimeout(timeout, TimeUnit.SECONDS)
|
||||||
@@ -61,7 +54,9 @@ object NetworkModule {
|
|||||||
.addInterceptor(requestInterceptor)
|
.addInterceptor(requestInterceptor)
|
||||||
.addInterceptor(responseInterceptor)
|
.addInterceptor(responseInterceptor)
|
||||||
.apply {
|
.apply {
|
||||||
if (BuildConfig.DEBUG) loggingInterceptor?.let { addInterceptor(it) }
|
if (BuildConfig.DEBUG) {
|
||||||
|
addInterceptor(DebugNetworkLoggingInterceptor(clientName))
|
||||||
|
}
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@@ -70,12 +65,11 @@ object NetworkModule {
|
|||||||
fun provideDefaultOkHttpClient(
|
fun provideDefaultOkHttpClient(
|
||||||
requestInterceptor: RequestInterceptor,
|
requestInterceptor: RequestInterceptor,
|
||||||
responseInterceptor: ResponseInterceptor,
|
responseInterceptor: ResponseInterceptor,
|
||||||
loggingInterceptor: HttpLoggingInterceptor,
|
|
||||||
): OkHttpClient = buildOkHttpClient(
|
): OkHttpClient = buildOkHttpClient(
|
||||||
TIMEOUT_SHORT,
|
TIMEOUT_SHORT,
|
||||||
requestInterceptor,
|
requestInterceptor,
|
||||||
responseInterceptor,
|
responseInterceptor,
|
||||||
loggingInterceptor,
|
"api",
|
||||||
)
|
)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@@ -84,7 +78,12 @@ object NetworkModule {
|
|||||||
fun provideUploadOkHttpClient(
|
fun provideUploadOkHttpClient(
|
||||||
requestInterceptor: RequestInterceptor,
|
requestInterceptor: RequestInterceptor,
|
||||||
responseInterceptor: ResponseInterceptor,
|
responseInterceptor: ResponseInterceptor,
|
||||||
): OkHttpClient = buildOkHttpClient(TIMEOUT_LONG, requestInterceptor, responseInterceptor, null)
|
): OkHttpClient = buildOkHttpClient(
|
||||||
|
TIMEOUT_LONG,
|
||||||
|
requestInterceptor,
|
||||||
|
responseInterceptor,
|
||||||
|
"upload",
|
||||||
|
)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -98,6 +97,11 @@ object NetworkModule {
|
|||||||
.callTimeout(TIMEOUT_LONG, TimeUnit.SECONDS)
|
.callTimeout(TIMEOUT_LONG, TimeUnit.SECONDS)
|
||||||
.addInterceptor(requestInterceptor)
|
.addInterceptor(requestInterceptor)
|
||||||
// 注意:不添加 ResponseInterceptor,避免将大文件加载到内存
|
// 注意:不添加 ResponseInterceptor,避免将大文件加载到内存
|
||||||
|
.apply {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
addInterceptor(DebugNetworkLoggingInterceptor("download"))
|
||||||
|
}
|
||||||
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private fun buildRetrofit(
|
private fun buildRetrofit(
|
||||||
@@ -127,4 +131,4 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
package com.yzx.kiosk.network.interceptor
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.yzx.kiosk.BuildConfig
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.MediaType
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okio.Buffer
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug-only HTTP logger used by every application-owned OkHttpClient.
|
||||||
|
*
|
||||||
|
* Text bodies are capped to avoid flooding Logcat or buffering large payloads. Binary,
|
||||||
|
* multipart, one-shot and duplex bodies are represented by metadata only. Response bodies are
|
||||||
|
* inspected with [Response.peekBody], so logging never consumes the body used by business code.
|
||||||
|
*/
|
||||||
|
class DebugNetworkLoggingInterceptor(
|
||||||
|
private val clientName: String = "api",
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
if (!BuildConfig.DEBUG) return chain.proceed(chain.request())
|
||||||
|
|
||||||
|
val request = chain.request()
|
||||||
|
val requestId = REQUEST_SEQUENCE.incrementAndGet()
|
||||||
|
val startedAt = System.nanoTime()
|
||||||
|
|
||||||
|
log("┌─ #$requestId [$clientName] --> ${request.method} ${redactedUrl(request)}")
|
||||||
|
logHeaders(requestId, "request", request.headers)
|
||||||
|
logRequestBody(requestId, request)
|
||||||
|
|
||||||
|
val response = try {
|
||||||
|
chain.proceed(request)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
val durationMs = elapsedMillis(startedAt)
|
||||||
|
log("└─ #$requestId [$clientName] <-- HTTP FAILED (${durationMs}ms): ${error.javaClass.simpleName}: ${error.message}")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
val durationMs = elapsedMillis(startedAt)
|
||||||
|
log("├─ #$requestId [$clientName] <-- ${response.code} ${response.message} (${durationMs}ms) ${redactedUrl(request)}")
|
||||||
|
logHeaders(requestId, "response", response.headers)
|
||||||
|
logResponseBody(requestId, response)
|
||||||
|
log("└─ #$requestId [$clientName] END HTTP")
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logRequestBody(requestId: Long, request: Request) {
|
||||||
|
val body = request.body ?: run {
|
||||||
|
log("#$requestId request body: <none>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val contentType = body.contentType()
|
||||||
|
val contentLength = runCatching { body.contentLength() }.getOrDefault(-1L)
|
||||||
|
val description = describeBody(contentType, contentLength)
|
||||||
|
|
||||||
|
if (body.isDuplex() || body.isOneShot()) {
|
||||||
|
log("#$requestId request body: $description <one-shot/duplex body omitted>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isText(contentType) || isEncoded(request.headers) || contentLength > MAX_BODY_BYTES) {
|
||||||
|
log("#$requestId request body: $description <binary/encoded/large body omitted>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val bodyText = runCatching {
|
||||||
|
Buffer().use { buffer ->
|
||||||
|
body.writeTo(buffer)
|
||||||
|
if (buffer.size > MAX_BODY_BYTES) {
|
||||||
|
"$description <body exceeded ${MAX_BODY_BYTES / 1024}KB while buffering; omitted>"
|
||||||
|
} else {
|
||||||
|
val charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
|
||||||
|
"$description\n${buffer.readString(charset)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrElse { "$description <unable to read: ${it.message}>" }
|
||||||
|
log("#$requestId request body: $bodyText")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logResponseBody(requestId: Long, response: Response) {
|
||||||
|
val body = response.body ?: run {
|
||||||
|
log("#$requestId response body: <none>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val contentType = body.contentType()
|
||||||
|
val contentLength = body.contentLength()
|
||||||
|
val description = describeBody(contentType, contentLength)
|
||||||
|
|
||||||
|
if (!isText(contentType) || isEncoded(response.headers)) {
|
||||||
|
log("#$requestId response body: $description <binary/encoded body omitted>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val bodyText = runCatching {
|
||||||
|
val preview = response.peekBody(MAX_BODY_BYTES)
|
||||||
|
val text = preview.string()
|
||||||
|
val truncated = contentLength > MAX_BODY_BYTES ||
|
||||||
|
(contentLength == -1L && preview.contentLength() >= MAX_BODY_BYTES)
|
||||||
|
buildString {
|
||||||
|
append(description).append('\n').append(text)
|
||||||
|
if (truncated) append("\n<response truncated at ${MAX_BODY_BYTES / 1024}KB>")
|
||||||
|
}
|
||||||
|
}.getOrElse { "$description <unable to preview: ${it.message}>" }
|
||||||
|
log("#$requestId response body: $bodyText")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logHeaders(requestId: Long, direction: String, headers: Headers) {
|
||||||
|
if (headers.size == 0) {
|
||||||
|
log("#$requestId $direction headers: <none>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val text = buildString {
|
||||||
|
append("#$requestId $direction headers:")
|
||||||
|
for (index in 0 until headers.size) {
|
||||||
|
val name = headers.name(index)
|
||||||
|
val value = if (isSensitiveHeader(name)) REDACTED else headers.value(index)
|
||||||
|
append('\n').append(name).append(": ").append(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun redactedUrl(request: Request): String {
|
||||||
|
val url = request.url
|
||||||
|
val builder = url.newBuilder()
|
||||||
|
url.queryParameterNames.forEach { name ->
|
||||||
|
if (isSensitiveName(name)) builder.setQueryParameter(name, REDACTED)
|
||||||
|
}
|
||||||
|
return builder.build().toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isEncoded(headers: Headers): Boolean {
|
||||||
|
val encoding = headers["Content-Encoding"] ?: return false
|
||||||
|
return !encoding.equals("identity", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isText(contentType: MediaType?): Boolean {
|
||||||
|
if (contentType == null) return false
|
||||||
|
val type = contentType.type.lowercase(Locale.US)
|
||||||
|
val subtype = contentType.subtype.lowercase(Locale.US)
|
||||||
|
if (type == "text") return true
|
||||||
|
return subtype.contains("json") ||
|
||||||
|
subtype.contains("xml") ||
|
||||||
|
subtype.contains("html") ||
|
||||||
|
subtype.contains("x-www-form-urlencoded") ||
|
||||||
|
subtype.contains("javascript")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun describeBody(contentType: MediaType?, contentLength: Long): String {
|
||||||
|
val length = if (contentLength >= 0) "$contentLength bytes" else "unknown length"
|
||||||
|
return "${contentType ?: "unknown content-type"}, $length"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSensitiveHeader(name: String): Boolean = SENSITIVE_NAMES.contains(name.lowercase(Locale.US))
|
||||||
|
|
||||||
|
private fun isSensitiveName(name: String): Boolean = SENSITIVE_NAMES.contains(name.lowercase(Locale.US))
|
||||||
|
|
||||||
|
private fun log(message: String) {
|
||||||
|
if (!BuildConfig.DEBUG) return
|
||||||
|
if (message.isEmpty()) {
|
||||||
|
Log.d(TAG, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var start = 0
|
||||||
|
while (start < message.length) {
|
||||||
|
val end = minOf(start + LOGCAT_CHUNK_SIZE, message.length)
|
||||||
|
Log.d(TAG, message.substring(start, end))
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun elapsedMillis(startedAt: Long): Long =
|
||||||
|
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "KIOSK-NET"
|
||||||
|
|
||||||
|
private const val MAX_BODY_BYTES = 64L * 1024L
|
||||||
|
private const val LOGCAT_CHUNK_SIZE = 3_500
|
||||||
|
private const val REDACTED = "██"
|
||||||
|
private val REQUEST_SEQUENCE = AtomicLong(0)
|
||||||
|
private val SENSITIVE_NAMES = setOf(
|
||||||
|
"authorization",
|
||||||
|
"token",
|
||||||
|
"access_token",
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"cookie",
|
||||||
|
"set-cookie",
|
||||||
|
"sn",
|
||||||
|
"device_sn",
|
||||||
|
"password",
|
||||||
|
"secret",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -46,7 +46,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
|
||||||
@@ -76,6 +76,12 @@ fun FaceRecognitionResultScreen(
|
|||||||
// 支付弹框状态
|
// 支付弹框状态
|
||||||
var showPayDialog by remember { mutableStateOf(false) }
|
var showPayDialog by remember { mutableStateOf(false) }
|
||||||
val payQrCodeUrl by viewModel.payQrCodeUrl.collectAsState()
|
val payQrCodeUrl by viewModel.payQrCodeUrl.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(viewModel) {
|
||||||
|
viewModel.dismissPayDialogEvents.collect {
|
||||||
|
showPayDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
FullScreenMode()
|
FullScreenMode()
|
||||||
|
|
||||||
@@ -265,15 +271,19 @@ fun FaceRecognitionResultScreen(
|
|||||||
|
|
||||||
// 支付弹框
|
// 支付弹框
|
||||||
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 监听处理
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -453,8 +463,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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
+178
-7
@@ -1,7 +1,5 @@
|
|||||||
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.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
@@ -11,6 +9,7 @@ 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.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
|
||||||
@@ -26,14 +25,23 @@ 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.Dispatchers
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
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.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
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -72,13 +80,23 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
|||||||
// 支付二维码URL
|
// 支付二维码URL
|
||||||
private val _payQrCodeUrl = MutableStateFlow<String?>(null)
|
private val _payQrCodeUrl = MutableStateFlow<String?>(null)
|
||||||
val payQrCodeUrl: StateFlow<String?> = _payQrCodeUrl.asStateFlow()
|
val payQrCodeUrl: StateFlow<String?> = _payQrCodeUrl.asStateFlow()
|
||||||
|
|
||||||
|
// 支付弹框关闭事件(支付完成、取消或退款时由 ViewModel 驱动关闭)
|
||||||
|
private val _dismissPayDialogEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
|
val dismissPayDialogEvents: SharedFlow<Unit> = _dismissPayDialogEvents.asSharedFlow()
|
||||||
|
|
||||||
|
private var activePaymentOrderNumber: String? = null
|
||||||
|
private var paymentPollingJob: Job? = null
|
||||||
|
private val paymentHandled = AtomicBoolean(false)
|
||||||
|
|
||||||
// 图片加载状态:URL -> (重试次数, 是否加载失败)
|
// 图片加载状态:URL -> (重试次数, 是否加载失败)
|
||||||
private val _imageLoadStates = MutableStateFlow<Map<String, ImageLoadState>>(emptyMap())
|
private val _imageLoadStates = MutableStateFlow<Map<String, ImageLoadState>>(emptyMap())
|
||||||
val imageLoadStates: StateFlow<Map<String, ImageLoadState>> = _imageLoadStates.asStateFlow()
|
val imageLoadStates: StateFlow<Map<String, ImageLoadState>> = _imageLoadStates.asStateFlow()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "FaceRecognitionResultViewModel"
|
||||||
private const val MAX_RETRY_COUNT = 3
|
private const val MAX_RETRY_COUNT = 3
|
||||||
|
private const val PAY_STATUS_POLL_INTERVAL_MS = 2_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ImageLoadState(
|
data class ImageLoadState(
|
||||||
@@ -93,9 +111,13 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
|||||||
.onEach { event ->
|
.onEach { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
is UploadPhotoEvent.PaySuccess -> {
|
is UploadPhotoEvent.PaySuccess -> {
|
||||||
// 支付成功,跳转到支付成功页面
|
LogUtils.d(TAG, "收到 WebSocket 支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, image_ids: ${event.imageIds}")
|
||||||
LogUtils.d("FaceRecognitionResultViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, image_ids: ${event.imageIds}")
|
handlePaymentSuccess(
|
||||||
navigateToPaySuccess(event.orderNumber, event.captureType, event.imageIds)
|
orderNumber = event.orderNumber,
|
||||||
|
captureType = event.captureType,
|
||||||
|
imageIds = event.imageIds,
|
||||||
|
source = "WebSocket"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// 其他事件不处理
|
// 其他事件不处理
|
||||||
@@ -330,10 +352,16 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
|||||||
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,6 +380,149 @@ 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)
|
||||||
|
navigateToPaySuccess(orderNumber, captureType, imageIds)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 跳转到支付成功页面
|
* 跳转到支付成功页面
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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.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
|
||||||
@@ -38,7 +39,6 @@ import okhttp3.OkHttpClient
|
|||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
import okhttp3.RequestBody.Companion.asRequestBody
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -260,23 +260,20 @@ class FaceRecognitionViewModel @Inject constructor(
|
|||||||
LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========")
|
LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========")
|
||||||
LogUtils.d(TAG, "URL: $requestUrl")
|
LogUtils.d(TAG, "URL: $requestUrl")
|
||||||
LogUtils.d(TAG, "Headers:")
|
LogUtils.d(TAG, "Headers:")
|
||||||
LogUtils.d(TAG, "Authorization: $authorization")
|
LogUtils.d(TAG, "Authorization: <redacted>")
|
||||||
LogUtils.d(TAG, "Parameters:")
|
LogUtils.d(TAG, "Parameters:")
|
||||||
LogUtils.d(TAG, "image: ${imageFile.name} (${imageFile.length()} bytes)")
|
LogUtils.d(TAG, "image: ${imageFile.name} (${imageFile.length()} bytes)")
|
||||||
LogUtils.d(TAG, "threshold: $thresholdValue")
|
LogUtils.d(TAG, "threshold: $thresholdValue")
|
||||||
LogUtils.d(TAG, "index_date: $indexDate")
|
LogUtils.d(TAG, "index_date: $indexDate")
|
||||||
LogUtils.d(TAG, "==========================================")
|
LogUtils.d(TAG, "==========================================")
|
||||||
|
|
||||||
// 创建日志拦截器
|
// Debug 环境记录请求与响应;图片等大文件只记录元数据,不读取文件内容
|
||||||
val loggingInterceptor = HttpLoggingInterceptor { message ->
|
|
||||||
LogUtils.d(TAG, message)
|
|
||||||
}.apply {
|
|
||||||
level = HttpLoggingInterceptor.Level.BODY
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建OkHttpClient,添加日志拦截器
|
|
||||||
val client = OkHttpClient.Builder()
|
val client = OkHttpClient.Builder()
|
||||||
.addInterceptor(loggingInterceptor)
|
.apply {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
|
||||||
|
}
|
||||||
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// 创建Retrofit实例
|
// 创建Retrofit实例
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import coil.request.ImageRequest
|
|||||||
import coil.size.Size
|
import coil.size.Size
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.yzx.kiosk.App
|
import com.yzx.kiosk.App
|
||||||
|
import com.yzx.kiosk.BuildConfig
|
||||||
import com.yzx.kiosk.datastore.AppStoreDataSource
|
import com.yzx.kiosk.datastore.AppStoreDataSource
|
||||||
|
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
|
||||||
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
|
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
|
||||||
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
||||||
import com.yzx.kiosk.network.model.response.BatchQueryResponse
|
import com.yzx.kiosk.network.model.response.BatchQueryResponse
|
||||||
@@ -65,6 +67,11 @@ class PrivacyPrintService @Inject constructor(
|
|||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.writeTimeout(10, TimeUnit.SECONDS)
|
.writeTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.apply {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
addInterceptor(DebugNetworkLoggingInterceptor("privacy-print"))
|
||||||
|
}
|
||||||
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// 缓存 AppState 实例,避免频繁调用 Lazy.get()
|
// 缓存 AppState 实例,避免频繁调用 Lazy.get()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.google.gson.Gson
|
|||||||
import com.google.gson.JsonObject
|
import com.google.gson.JsonObject
|
||||||
import com.yzx.kiosk.BuildConfig
|
import com.yzx.kiosk.BuildConfig
|
||||||
import com.yzx.kiosk.datastore.AppStoreDataSource
|
import com.yzx.kiosk.datastore.AppStoreDataSource
|
||||||
|
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
|
||||||
import com.yzx.kiosk.network.repository.NetWorkRepository
|
import com.yzx.kiosk.network.repository.NetWorkRepository
|
||||||
import com.yzx.kiosk.ui.poster.ScenicLivePosterController
|
import com.yzx.kiosk.ui.poster.ScenicLivePosterController
|
||||||
import com.yzx.kiosk.priter.PrintStatusManager
|
import com.yzx.kiosk.priter.PrintStatusManager
|
||||||
@@ -100,6 +101,11 @@ class WebSocketService @Inject constructor(
|
|||||||
.readTimeout(10, TimeUnit.SECONDS)
|
.readTimeout(10, TimeUnit.SECONDS)
|
||||||
.writeTimeout(10, TimeUnit.SECONDS)
|
.writeTimeout(10, TimeUnit.SECONDS)
|
||||||
.pingInterval(30, TimeUnit.SECONDS) // 自动 ping
|
.pingInterval(30, TimeUnit.SECONDS) // 自动 ping
|
||||||
|
.apply {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
addInterceptor(DebugNetworkLoggingInterceptor("websocket"))
|
||||||
|
}
|
||||||
|
}
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -971,4 +977,3 @@ sealed class UploadPhotoEvent {
|
|||||||
val imageIds: List<Int>
|
val imageIds: List<Int>
|
||||||
) : UploadPhotoEvent()
|
) : UploadPhotoEvent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<UploadPhotoEvent>`,页面 ViewModel 各自订阅;隐私打印则直接进入后台打印队列。
|
||||||
|
|
||||||
|
## 6. 网络认证和环境
|
||||||
|
|
||||||
|
普通 HTTP 请求由 [`RequestInterceptor.kt`](../app/src/main/java/com/yzx/kiosk/network/interceptor/RequestInterceptor.kt) 添加:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sn = 设备 SN
|
||||||
|
timestamp = 当前秒级时间戳
|
||||||
|
token = MD5(sn + deviceSecret + timestamp)
|
||||||
|
```
|
||||||
|
|
||||||
|
设备密钥需要先在设备配置页录入。没有密钥时客户端不会生成 `token` 请求头,首页会提示先配置设备密钥。
|
||||||
|
|
||||||
|
构建环境由 `BuildConfig` 区分:
|
||||||
|
|
||||||
|
| 构建类型 | HTTP API | WebSocket |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Debug | `api-test.zhifly.cn` | 测试 WSS |
|
||||||
|
| Release | `api.zhifly.cn` | 正式 WSS |
|
||||||
|
|
||||||
|
配置位置为 [`app/build.gradle.kts`](../app/build.gradle.kts),网络组件装配位于 [`NetworkModule.kt`](../app/src/main/java/com/yzx/kiosk/network/di/NetworkModule.kt)。
|
||||||
|
|
||||||
|
## 7. 工程采用的技术
|
||||||
|
|
||||||
|
### 7.1 语言、构建和 UI
|
||||||
|
|
||||||
|
| 技术 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| Kotlin 2.1.10 | 主要开发语言,JVM Target 11 |
|
||||||
|
| Gradle 8.11.1 + AGP 8.10.0 | Android 构建系统 |
|
||||||
|
| Jetpack Compose + Material 3 | 主要页面 UI |
|
||||||
|
| ViewBinding + XML | Splash 等少量传统页面 |
|
||||||
|
| Navigation Compose | 页面路由和回退栈 |
|
||||||
|
| StateFlow / SharedFlow | 页面状态、全局状态和实时事件 |
|
||||||
|
| Coroutines | 网络、打印、上传下载和倒计时等异步任务 |
|
||||||
|
|
||||||
|
整体采用接近单 Activity + MVVM 的结构:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
UI["Compose Screen"] --> VM["Hilt ViewModel"]
|
||||||
|
VM --> Repo["Repository / Manager"]
|
||||||
|
Repo --> Api["Retrofit Service"]
|
||||||
|
Repo --> Local["Room / MMKV"]
|
||||||
|
VM --> Device["打印机、相机、播放器"]
|
||||||
|
WS["WebSocketService"] --> Flow["SharedFlow / StateFlow"]
|
||||||
|
Flow --> VM
|
||||||
|
VM --> Nav["AppNavigator"]
|
||||||
|
Nav --> UI
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 依赖注入和架构组件
|
||||||
|
|
||||||
|
- Hilt:Application、Activity、ViewModel、Repository 和 Service 的依赖注入
|
||||||
|
- KSP:Hilt、Room 和 Glide 等代码生成
|
||||||
|
- Lifecycle ViewModel:页面状态和协程生命周期
|
||||||
|
- 自定义 `AppNavigator`:通过 SharedFlow 解耦 ViewModel 与 NavController
|
||||||
|
- `BaseViewModel`:统一导航、Loading、网络结果和登录失效处理
|
||||||
|
|
||||||
|
### 7.3 网络与实时通信
|
||||||
|
|
||||||
|
- Retrofit + Gson:业务 REST API
|
||||||
|
- OkHttp:HTTP、APK 下载和 WebSocket
|
||||||
|
- 自定义请求拦截器:设备 SN、时间戳和签名 Token
|
||||||
|
- 自定义响应拦截器与结果层:统一解析业务 code
|
||||||
|
- WebSocket:扫码、照片列表、支付成功、隐私打印、直播和设备心跳
|
||||||
|
|
||||||
|
### 7.4 数据存储
|
||||||
|
|
||||||
|
| 存储 | 保存内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| MMKV | 设备密钥、设备配置、Socket Token、盒子地址、纸张数、打印记录和开关状态 |
|
||||||
|
| Room `AppDatabase` | 运营素材上传任务及进度 |
|
||||||
|
| Room `CloudDatabase` | OSS 上传、下载任务 |
|
||||||
|
| 应用私有文件目录 | 处理后的打印 BMP、缓存和下载文件 |
|
||||||
|
| Coil 磁盘缓存 | 网络图片缓存,当前限制约 50 MB |
|
||||||
|
|
||||||
|
MMKV 的统一访问入口是 [`AppStoreDataSource.kt`](../app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSource.kt),全局响应式状态集中在 [`AppState.kt`](../app/src/main/java/com/yzx/kiosk/datastore/AppState.kt)。
|
||||||
|
|
||||||
|
### 7.5 图片、相机和扫码
|
||||||
|
|
||||||
|
- CameraX:人脸识别页拍照
|
||||||
|
- Coil / Glide:网络图片加载
|
||||||
|
- uCrop:照片裁剪
|
||||||
|
- PictureSelector:本地运营素材选择
|
||||||
|
- ML Kit Barcode Scanning + ZXing:扫码识别和二维码生成
|
||||||
|
- Android Bitmap/Canvas:打印前旋转、缩放和白底合成
|
||||||
|
|
||||||
|
### 7.6 音视频与云能力
|
||||||
|
|
||||||
|
- AndroidX Media3 / ExoPlayer:视频、音频和 RTMP 直播
|
||||||
|
- 阿里云 OSS SDK:素材上传下载
|
||||||
|
- 阿里云语音 SDK:状态语音合成
|
||||||
|
- 高德地图 SDK:定位相关能力
|
||||||
|
- DNP PhotoPrint SDK:照片打印机控制
|
||||||
|
|
||||||
|
本地闭源 SDK 位于 `app/libs/`,这是工程能否完整构建和连接硬件的重要前提。
|
||||||
|
|
||||||
|
## 8. 代码目录怎么理解
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/src/main/java/com/yzx/kiosk/
|
||||||
|
├── App.kt # Application 初始化
|
||||||
|
├── SplashActivity.kt # 启动页
|
||||||
|
├── MainActivity.kt # 单 Activity 容器、Socket 和待机层
|
||||||
|
├── audio/ # 在线/本地音频播放
|
||||||
|
├── base/ # ViewModel 基类
|
||||||
|
├── component/ # 通用 Compose 组件和全局 UI
|
||||||
|
├── datastore/ # MMKV 封装和全局状态
|
||||||
|
├── navigation/ # 路由、导航事件和 NavHost
|
||||||
|
├── network/
|
||||||
|
│ ├── db/ # 上传任务 Room 数据库
|
||||||
|
│ ├── di/ # Hilt 网络与协程模块
|
||||||
|
│ ├── interceptor/ # 请求签名和响应处理
|
||||||
|
│ ├── manager/ # 素材上传任务调度
|
||||||
|
│ ├── model/ # 请求、响应和实体
|
||||||
|
│ ├── repository/ # 数据仓库
|
||||||
|
│ └── service/ # Retrofit 接口
|
||||||
|
├── priter/ # DNP 打印服务和打印状态
|
||||||
|
├── receiver/ # 开机/更新广播
|
||||||
|
├── ui/
|
||||||
|
│ ├── device/ # 设备和运营素材配置
|
||||||
|
│ ├── face/ # 人脸识别业务
|
||||||
|
│ ├── home/ # 首页
|
||||||
|
│ ├── poster/ # 待机海报和直播
|
||||||
|
│ ├── printer/ # 打印机管理
|
||||||
|
│ ├── setting/ # 设置、协议和帮助
|
||||||
|
│ └── upload/ # 上传、选片、支付、打印
|
||||||
|
├── utils/ # 更新、日志、二维码、超时等工具
|
||||||
|
│ └── cloud/ # OSS 上传下载及第二套 Room 数据库
|
||||||
|
└── websocket/ # 长连接、实时事件和隐私打印队列
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 建议的代码阅读顺序
|
||||||
|
|
||||||
|
第一次接手时,不建议从工具类或所有依赖开始看。按下面顺序更容易建立上下文:
|
||||||
|
|
||||||
|
1. [`README.md`](../README.md):先把工程跑起来,理解环境和硬件要求。
|
||||||
|
2. [`AppRoutes.kt`](../app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt) 和 [`AppNavHost.kt`](../app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt):知道有哪些页面和用户动线。
|
||||||
|
3. [`HomeScreen.kt`](../app/src/main/java/com/yzx/kiosk/ui/home/view/HomeScreen.kt) 和 [`HomeViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/home/viewmodel/HomeViewModel.kt):理解首页配置如何驱动业务入口。
|
||||||
|
4. `ui/upload/viewmodel/`:完整看一遍上传、选片、支付、打印主链路。
|
||||||
|
5. `ui/face/viewmodel/`:理解第二条照片来源链路。
|
||||||
|
6. [`WebSocketService.kt`](../app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt):理解页面为什么会因服务端消息自动跳转。
|
||||||
|
7. [`PrinterService.kt`](../app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt) 和 [`PrivacyPrintService.kt`](../app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt):理解硬件和后台任务。
|
||||||
|
8. [`NetworkService.kt`](../app/src/main/java/com/yzx/kiosk/network/service/NetworkService.kt) 与 [`AppStoreDataSource.kt`](../app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSource.kt):梳理接口和持久化字段。
|
||||||
|
9. `ui/device/`、`utils/cloud/` 和 `VersionUpdateManager`:最后看运营配置、素材和升级能力。
|
||||||
|
10. [`工程代码审查报告.md`](工程代码审查报告.md):了解当前缺陷和发布风险。
|
||||||
|
|
||||||
|
## 10. 接手时最容易混淆的几个概念
|
||||||
|
|
||||||
|
### capture type
|
||||||
|
|
||||||
|
- `1`:用户从手机上传的照片
|
||||||
|
- `2`:通过人脸搜索得到的景区照片
|
||||||
|
|
||||||
|
它会贯穿计价、支付、打印通知和订单完成接口。
|
||||||
|
|
||||||
|
### file_map
|
||||||
|
|
||||||
|
一张照片不只有一个 URL,而是同时包含图片 ID、OSS 原图/缩略图地址和 LAN 原图/缩略图地址。打印必须保留图片 ID,服务端才能正确记录每张照片的状态。
|
||||||
|
|
||||||
|
### 设备 SN、设备密钥和 Socket Token
|
||||||
|
|
||||||
|
- 设备 SN:当前主要取 Android ID,标识终端
|
||||||
|
- 设备密钥:运维录入,用于普通 HTTP 请求签名
|
||||||
|
- Socket Token:后端接口动态获取,用于 WebSocket 绑定
|
||||||
|
|
||||||
|
这三个值用途不同,排查鉴权问题时不要混在一起。
|
||||||
|
|
||||||
|
### 公网模式和 LAN 模式
|
||||||
|
|
||||||
|
切换的是照片盒子服务和照片 URL 的访问方式,不是 Debug/Release 环境切换。Debug/Release 由构建类型决定,LAN/公网由设备设置决定。
|
||||||
|
|
||||||
|
### 两类打印
|
||||||
|
|
||||||
|
- 前台打印:游客在终端完成上传/人脸业务后进入 `PrintingScreen`
|
||||||
|
- 后台隐私打印:WebSocket `code = 4` 直接进入 `PrivacyPrintService`
|
||||||
|
|
||||||
|
两条链路最终都调用 `PrinterService`,但任务队列、UI 展示和状态上报入口不同。
|
||||||
|
|
||||||
|
## 11. 接手后的优先验证清单
|
||||||
|
|
||||||
|
在开始改业务前,建议用测试环境完成以下闭环:
|
||||||
|
|
||||||
|
1. 配置设备密钥,确认设备配置接口成功。
|
||||||
|
2. 确认 WebSocket 绑定成功且心跳包含正确纸张和打印机状态。
|
||||||
|
3. 手机扫码上传一张照片,走完选片、计价和支付事件。
|
||||||
|
4. 使用相机完成一次人脸搜索,分别测试公网和 LAN 地址。
|
||||||
|
5. 连接 RX1 或 QW410,各打印一张横图和竖图。
|
||||||
|
6. 验证单张 `print-notify` 和最终 `print-complete` 到达服务端。
|
||||||
|
7. 发送一条隐私打印任务,确认不会阻断前台用户操作。
|
||||||
|
8. 验证海报、视频、直播、BGM 和无操作返回逻辑。
|
||||||
|
9. 验证重启后开机自启动、配置恢复和 Socket 重连。
|
||||||
|
10. 在准备发布前阅读代码审查报告并处理签名、隐私、打印结果和并发问题。
|
||||||
|
|
||||||
|
## 12. 当前需要特别注意的技术债
|
||||||
|
|
||||||
|
本文重点是业务和技术导读,不展开缺陷细节;但以下问题会直接影响你对现有实现的判断:
|
||||||
|
|
||||||
|
- Release 当前实际使用 Debug 签名,且签名信息直接写在 Gradle 配置中。
|
||||||
|
- 打印任务加入 SDK 队列后不等于物理打印成功,当前成功判定不够可靠。
|
||||||
|
- 部分 Token、设备密钥和业务配置保存在未加密 MMKV 中。
|
||||||
|
- WebSocket、打印通知及上传下载存在并发和恢复方面的风险。
|
||||||
|
- APK 更新缺少足够的完整性与签名校验。
|
||||||
|
- 自动化测试覆盖少,Lint 当前仍有待处理项。
|
||||||
|
|
||||||
|
具体证据、文件位置和修复建议见 [`工程代码审查报告.md`](工程代码审查报告.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
如果要修改某个业务,优先从对应页面的 ViewModel 顺着 Repository、WebSocket 或 PrinterService 向下追踪;如果问题表现为“页面没有自动跳转”,通常还要同时检查 WebSocket 消息是否到达以及 `SharedFlow` 订阅是否仍处于活跃生命周期。
|
||||||
Reference in New Issue
Block a user