diff --git a/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionStore.kt b/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionStore.kt index 8aac495..e9b3917 100644 --- a/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionStore.kt +++ b/frontend/android/app/src/main/kotlin/com/nx/miaoji/RecognitionStore.kt @@ -102,9 +102,11 @@ class RecognitionStore(context: Context) : PaymentParser.sha256("${signal.packageName}|flow|$it") } val strongKey = orderStrongKey ?: flowStrongKey + val clientRequestId = clientRequestIdFor(signal) val signalHigh = signal.channel in setOf("accessibility", "local_ocr") && signal.evidenceConfidence == "high" val existing = findMergeCandidate(signal, channelBit, merchantHash, strongKey, now) + ?: findByClientRequestId(clientRequestId) val id: String if (existing != null) { id = existing.id @@ -123,6 +125,7 @@ class RecognitionStore(context: Context) : put("known_template", if (existing.knownTemplate || signal.knownTemplate) 1 else 0) put("high_confidence", if (high) 1 else 0) put("payload_encrypted", encryptPayload(mergedPayload)) + if (high && existing.state == "pending_confirm") put("state", "auto_ready") put("updated_at", now) put("available_at", now + MERGE_DELAY_MS) }, @@ -138,7 +141,7 @@ class RecognitionStore(context: Context) : null, ContentValues().apply { put("id", id) - put("client_request_id", "recognition-$id") + put("client_request_id", clientRequestId) put("package_name", signal.packageName) put("amount_cents", signal.amountCents) put("direction", signal.type) @@ -173,6 +176,14 @@ class RecognitionStore(context: Context) : } } + private fun findByClientRequestId(clientRequestId: String): CandidateRow? = + readableDatabase.rawQuery( + "SELECT * FROM candidates WHERE client_request_id = ? AND state NOT IN ('undone','expired') LIMIT 1", + arrayOf(clientRequestId), + ).use { cursor -> + if (cursor.moveToFirst()) row(cursor) else null + } + @Synchronized fun finalizeCandidate(id: String): StoredCandidate? { val now = System.currentTimeMillis() @@ -267,7 +278,6 @@ class RecognitionStore(context: Context) : if (cursor.moveToFirst()) return row(cursor) } } - if (signal.channel == "local_ocr" && signal.flowSessionId != null) return null val since = signal.occurredAtEpochMs - NO_ORDER_WINDOW_MS val until = signal.occurredAtEpochMs + NO_ORDER_WINDOW_MS readableDatabase.rawQuery( @@ -291,7 +301,12 @@ class RecognitionStore(context: Context) : while (cursor.moveToNext()) { val candidate = row(cursor) val hasSameChannel = candidate.channelMask and channelBit != 0 - if (!hasSameChannel || now - candidate.updatedAt <= SAME_CHANNEL_DEBOUNCE_MS) { + val sameResult = signal.resultFingerprint != null && + signal.resultFingerprint == candidate.resultFingerprint + if (sameResult || + !hasSameChannel || + now - candidate.updatedAt <= SAME_CHANNEL_DEBOUNCE_MS + ) { return candidate } } @@ -330,10 +345,12 @@ class RecognitionStore(context: Context) : ?: JSONObject() return CandidateRow( id = cursor.getString(cursor.getColumnIndexOrThrow("id")), + state = cursor.getString(cursor.getColumnIndexOrThrow("state")), channelMask = cursor.getInt(cursor.getColumnIndexOrThrow("channel_mask")), knownTemplate = cursor.getInt(cursor.getColumnIndexOrThrow("known_template")) == 1, highConfidence = cursor.getInt(cursor.getColumnIndexOrThrow("high_confidence")) == 1, updatedAt = cursor.getLong(cursor.getColumnIndexOrThrow("updated_at")), + resultFingerprint = payload.optString("resultFingerprint").takeIf(String::isNotBlank), payload = payload, ) } @@ -411,10 +428,12 @@ class RecognitionStore(context: Context) : private data class CandidateRow( val id: String, + val state: String, val channelMask: Int, val knownTemplate: Boolean, val highConfidence: Boolean, val updatedAt: Long, + val resultFingerprint: String?, val payload: JSONObject, ) @@ -424,5 +443,17 @@ class RecognitionStore(context: Context) : private const val NO_ORDER_WINDOW_MS = 90_000L private const val SAME_CHANNEL_DEBOUNCE_MS = 10_000L private const val EXPIRE_MS = 7L * 24L * 60L * 60L * 1000L + + internal fun clientRequestIdFor(signal: PaymentSignal): String { + val basis = when { + !signal.orderId.isNullOrBlank() -> + "${signal.packageName}|order|${signal.orderId.trim()}" + !signal.flowSessionId.isNullOrBlank() -> + "${signal.packageName}|flow|${signal.flowSessionId}" + else -> + "${signal.packageName}|event|${signal.sourceEventId}" + } + return "recognition-${PaymentParser.sha256(basis).take(52)}" + } } } diff --git a/frontend/android/app/src/test/kotlin/com/nx/miaoji/PaymentParserTest.kt b/frontend/android/app/src/test/kotlin/com/nx/miaoji/PaymentParserTest.kt index 054306b..4708709 100644 --- a/frontend/android/app/src/test/kotlin/com/nx/miaoji/PaymentParserTest.kt +++ b/frontend/android/app/src/test/kotlin/com/nx/miaoji/PaymentParserTest.kt @@ -2,6 +2,7 @@ package com.nx.miaoji import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -254,4 +255,73 @@ class PaymentParserTest { assertFalse(preview.contains("丁伊文")) assertFalse(preview.contains("202607221234567890")) } + + @Test + fun recognitionClientRequestIdIsStableWithinPaymentFlow() { + val treeSignal = paymentSignal( + channel = "accessibility", + sourceEventId = "a:wechat:flow-123", + flowSessionId = "flow-123", + ) + val ocrSignal = paymentSignal( + channel = "local_ocr", + sourceEventId = "ocr:wechat:flow-123", + flowSessionId = "flow-123", + ) + val otherFlow = paymentSignal( + channel = "accessibility", + sourceEventId = "a:wechat:flow-456", + flowSessionId = "flow-456", + ) + + val requestId = RecognitionStore.clientRequestIdFor(treeSignal) + assertEquals(requestId, RecognitionStore.clientRequestIdFor(ocrSignal)) + assertNotEquals(requestId, RecognitionStore.clientRequestIdFor(otherFlow)) + assertTrue(requestId.length <= 64) + } + + @Test + fun recognitionClientRequestIdPrefersOrderIdAcrossFlows() { + val first = paymentSignal( + channel = "accessibility", + sourceEventId = "a:wechat:flow-a", + flowSessionId = "flow-a", + orderId = "202607251234567890", + ) + val second = paymentSignal( + channel = "local_ocr", + sourceEventId = "ocr:wechat:flow-b", + flowSessionId = "flow-b", + orderId = "202607251234567890", + ) + + assertEquals( + RecognitionStore.clientRequestIdFor(first), + RecognitionStore.clientRequestIdFor(second), + ) + } + + private fun paymentSignal( + channel: String, + sourceEventId: String, + flowSessionId: String?, + orderId: String? = null, + ): PaymentSignal = PaymentSignal( + packageName = PaymentParser.WECHAT, + channel = channel, + amountCents = 2_000L, + type = "expense", + merchant = "测试商户", + orderId = orderId, + occurredAtEpochMs = 1_000L, + knownTemplate = true, + sourceEventId = sourceEventId, + sourceText = "测试支付", + flowSessionId = flowSessionId, + evidenceConfidence = "high", + recognitionKind = "payment", + categoryHint = null, + amountSource = "result", + resultFingerprint = "fingerprint", + ) }