Fix accessibility recognition idempotency
This commit is contained in:
@@ -102,9 +102,11 @@ class RecognitionStore(context: Context) :
|
|||||||
PaymentParser.sha256("${signal.packageName}|flow|$it")
|
PaymentParser.sha256("${signal.packageName}|flow|$it")
|
||||||
}
|
}
|
||||||
val strongKey = orderStrongKey ?: flowStrongKey
|
val strongKey = orderStrongKey ?: flowStrongKey
|
||||||
|
val clientRequestId = clientRequestIdFor(signal)
|
||||||
val signalHigh = signal.channel in setOf("accessibility", "local_ocr") &&
|
val signalHigh = signal.channel in setOf("accessibility", "local_ocr") &&
|
||||||
signal.evidenceConfidence == "high"
|
signal.evidenceConfidence == "high"
|
||||||
val existing = findMergeCandidate(signal, channelBit, merchantHash, strongKey, now)
|
val existing = findMergeCandidate(signal, channelBit, merchantHash, strongKey, now)
|
||||||
|
?: findByClientRequestId(clientRequestId)
|
||||||
val id: String
|
val id: String
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
id = existing.id
|
id = existing.id
|
||||||
@@ -123,6 +125,7 @@ class RecognitionStore(context: Context) :
|
|||||||
put("known_template", if (existing.knownTemplate || signal.knownTemplate) 1 else 0)
|
put("known_template", if (existing.knownTemplate || signal.knownTemplate) 1 else 0)
|
||||||
put("high_confidence", if (high) 1 else 0)
|
put("high_confidence", if (high) 1 else 0)
|
||||||
put("payload_encrypted", encryptPayload(mergedPayload))
|
put("payload_encrypted", encryptPayload(mergedPayload))
|
||||||
|
if (high && existing.state == "pending_confirm") put("state", "auto_ready")
|
||||||
put("updated_at", now)
|
put("updated_at", now)
|
||||||
put("available_at", now + MERGE_DELAY_MS)
|
put("available_at", now + MERGE_DELAY_MS)
|
||||||
},
|
},
|
||||||
@@ -138,7 +141,7 @@ class RecognitionStore(context: Context) :
|
|||||||
null,
|
null,
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
put("id", id)
|
put("id", id)
|
||||||
put("client_request_id", "recognition-$id")
|
put("client_request_id", clientRequestId)
|
||||||
put("package_name", signal.packageName)
|
put("package_name", signal.packageName)
|
||||||
put("amount_cents", signal.amountCents)
|
put("amount_cents", signal.amountCents)
|
||||||
put("direction", signal.type)
|
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
|
@Synchronized
|
||||||
fun finalizeCandidate(id: String): StoredCandidate? {
|
fun finalizeCandidate(id: String): StoredCandidate? {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
@@ -267,7 +278,6 @@ class RecognitionStore(context: Context) :
|
|||||||
if (cursor.moveToFirst()) return row(cursor)
|
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 since = signal.occurredAtEpochMs - NO_ORDER_WINDOW_MS
|
||||||
val until = signal.occurredAtEpochMs + NO_ORDER_WINDOW_MS
|
val until = signal.occurredAtEpochMs + NO_ORDER_WINDOW_MS
|
||||||
readableDatabase.rawQuery(
|
readableDatabase.rawQuery(
|
||||||
@@ -291,7 +301,12 @@ class RecognitionStore(context: Context) :
|
|||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
val candidate = row(cursor)
|
val candidate = row(cursor)
|
||||||
val hasSameChannel = candidate.channelMask and channelBit != 0
|
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
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,10 +345,12 @@ class RecognitionStore(context: Context) :
|
|||||||
?: JSONObject()
|
?: JSONObject()
|
||||||
return CandidateRow(
|
return CandidateRow(
|
||||||
id = cursor.getString(cursor.getColumnIndexOrThrow("id")),
|
id = cursor.getString(cursor.getColumnIndexOrThrow("id")),
|
||||||
|
state = cursor.getString(cursor.getColumnIndexOrThrow("state")),
|
||||||
channelMask = cursor.getInt(cursor.getColumnIndexOrThrow("channel_mask")),
|
channelMask = cursor.getInt(cursor.getColumnIndexOrThrow("channel_mask")),
|
||||||
knownTemplate = cursor.getInt(cursor.getColumnIndexOrThrow("known_template")) == 1,
|
knownTemplate = cursor.getInt(cursor.getColumnIndexOrThrow("known_template")) == 1,
|
||||||
highConfidence = cursor.getInt(cursor.getColumnIndexOrThrow("high_confidence")) == 1,
|
highConfidence = cursor.getInt(cursor.getColumnIndexOrThrow("high_confidence")) == 1,
|
||||||
updatedAt = cursor.getLong(cursor.getColumnIndexOrThrow("updated_at")),
|
updatedAt = cursor.getLong(cursor.getColumnIndexOrThrow("updated_at")),
|
||||||
|
resultFingerprint = payload.optString("resultFingerprint").takeIf(String::isNotBlank),
|
||||||
payload = payload,
|
payload = payload,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -411,10 +428,12 @@ class RecognitionStore(context: Context) :
|
|||||||
|
|
||||||
private data class CandidateRow(
|
private data class CandidateRow(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
val state: String,
|
||||||
val channelMask: Int,
|
val channelMask: Int,
|
||||||
val knownTemplate: Boolean,
|
val knownTemplate: Boolean,
|
||||||
val highConfidence: Boolean,
|
val highConfidence: Boolean,
|
||||||
val updatedAt: Long,
|
val updatedAt: Long,
|
||||||
|
val resultFingerprint: String?,
|
||||||
val payload: JSONObject,
|
val payload: JSONObject,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -424,5 +443,17 @@ class RecognitionStore(context: Context) :
|
|||||||
private const val NO_ORDER_WINDOW_MS = 90_000L
|
private const val NO_ORDER_WINDOW_MS = 90_000L
|
||||||
private const val SAME_CHANNEL_DEBOUNCE_MS = 10_000L
|
private const val SAME_CHANNEL_DEBOUNCE_MS = 10_000L
|
||||||
private const val EXPIRE_MS = 7L * 24L * 60L * 60L * 1000L
|
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.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -254,4 +255,73 @@ class PaymentParserTest {
|
|||||||
assertFalse(preview.contains("丁伊文"))
|
assertFalse(preview.contains("丁伊文"))
|
||||||
assertFalse(preview.contains("202607221234567890"))
|
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