Fix accessibility recognition idempotency

This commit is contained in:
2026-07-25 13:35:58 +08:00
parent 6396eabb87
commit eb8909a192
2 changed files with 104 additions and 3 deletions
@@ -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)}"
}
}
}
@@ -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",
)
}