Fix accessibility recognition idempotency
This commit is contained in:
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user