From 87192f75d4b16c027ca8451f7208614aeac82ca8 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 8 Oct 2026 17:49:02 +0800 Subject: [PATCH] feat(android): discover last repurchase batch safely (#367) --- .../goauto/agent/service/RepurchaseBatch.kt | 153 ++++++++++++++ .../agent/service/RepurchaseBatchTest.kt | 191 ++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/service/RepurchaseBatch.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/service/RepurchaseBatchTest.kt diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/RepurchaseBatch.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/RepurchaseBatch.kt new file mode 100644 index 0000000..04553ff --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/RepurchaseBatch.kt @@ -0,0 +1,153 @@ +package cn.ilapage.goauto.agent.service + +import cn.ilapage.goauto.agent.network.HistoryPage +import cn.ilapage.goauto.agent.network.PurchaseHistoryItem +import java.text.ParsePosition +import java.text.SimpleDateFormat +import java.util.Collections +import java.util.Locale +import java.util.TimeZone + +data class RepurchaseHistoryQuery(val page: Int, val pageSize: Int = 50, val days: Int = 30, val status: String? = null, val taskNo: String? = null) + +enum class RepurchaseBatchFailure { PAGE_FAILED, INVALID_PAGE, INVALID_TIMESTAMP, INVALID_ORDER, INCOMPLETE_BATCH, LIST_CHANGED, PAGE_LIMIT } + +class RepurchaseBatchException(val reason: RepurchaseBatchFailure) : IllegalStateException(reason.name) + +class RepurchaseBatch internal constructor(items: List) { + val items: List = immutable(items) + val candidates: List = immutable(items.filter { it.status == "failed" && it.retryable }) + val failures: List = immutable(items.filter { it.status == "failed" }) + val skips: List = immutable(failures.filterNot { it.retryable }) + val headTaskId = items.firstOrNull()?.taskId + val newestCreatedAt = items.firstOrNull()?.createdAt + val oldestCreatedAt = items.lastOrNull()?.createdAt + + private fun immutable(items: List): List = + Collections.unmodifiableList(ArrayList(items)) +} + +/** Read-only discovery. The callback must use the current device's authenticated history endpoint. + * + * Offset overlap is tolerated; a changed head is rejected. This is not a snapshot guarantee for + * arbitrary concurrent deletion, reassignment or timestamp edits on the server. + */ +class RepurchaseBatchDiscovery( + private val fetchPage: (RepurchaseHistoryQuery) -> HistoryPage, + private val nowMillis: () -> Long = System::currentTimeMillis, + private val maxPages: Int = 200, +) { + init { require(maxPages > 0) } + + fun discover(): RepurchaseBatch { + val startedAt = nowMillis() + val seen = linkedMapOf() + val batch = mutableListOf() + var head: Long? = null + var previous: Entry? = null + var originalTotal: Long? = null + var foundBoundary = false + var reachedEnd = false + for (pageNumber in 1..maxPages) { + val page = readPage(pageNumber) + if (originalTotal == null) originalTotal = page.total + else if (page.total != originalTotal) refuse(RepurchaseBatchFailure.LIST_CHANGED) + val entries = parseOrdered(page.items) + if (pageNumber == 1) head = entries.firstOrNull()?.item?.taskId + for (entry in entries) { + val duplicate = seen[entry.item.taskId] + if (duplicate != null) { + if (duplicate.time != entry.time) refuse(RepurchaseBatchFailure.INVALID_ORDER) + continue + } + val prior = previous + if (prior != null) { + if (ENTRY_ORDER.compare(prior, entry) > 0) refuse(RepurchaseBatchFailure.INVALID_ORDER) + if (prior.time.moreThanSixtySecondsAfter(entry.time)) { + foundBoundary = true + break + } + } + seen[entry.item.taskId] = entry + batch += entry.item + previous = entry + } + if (foundBoundary) break + if (pageNumber.toLong() * PAGE_SIZE >= page.total) { + // Offset overlap without enough unique rows cannot establish a complete tail. + if (seen.size.toLong() != page.total) refuse(RepurchaseBatchFailure.INCOMPLETE_BATCH) + reachedEnd = true + break + } + } + if (!foundBoundary && !reachedEnd) refuse(RepurchaseBatchFailure.PAGE_LIMIT) + + val finalPage = readPage(1) + val finalHead = parseOrdered(finalPage.items).firstOrNull()?.item?.taskId + if (finalHead != head || finalPage.total != originalTotal) refuse(RepurchaseBatchFailure.LIST_CHANGED) + if (reachedEnd && previous != null) { + // The API applies a rolling 30-day range, not a midnight-based date filter. + // Use the later clock reading because the floor can advance during pagination. + val floorMillis = maxOf(startedAt, nowMillis()) - DAYS * 24L * 60 * 60 * 1000 + val floorSeconds = floorMillis / 1000 - if (floorMillis < 0 && floorMillis % 1000 != 0L) 1 else 0 + val floor = Timestamp(floorSeconds, ((floorMillis - floorSeconds * 1000) * 1_000_000).toInt()) + if (!previous.time.moreThanSixtySecondsAfter(floor)) refuse(RepurchaseBatchFailure.INCOMPLETE_BATCH) + } + return RepurchaseBatch(batch) + } + + private fun readPage(pageNumber: Int): HistoryPage { + val page = try { + fetchPage(RepurchaseHistoryQuery(page = pageNumber)) + } catch (_: Exception) { + // Do not expose a network exception that might contain credentials or response data. + refuse(RepurchaseBatchFailure.PAGE_FAILED) + } + val offset = (pageNumber - 1L) * PAGE_SIZE + if (page.page != pageNumber || page.pageSize != PAGE_SIZE || page.total < 0 || + page.items.size.toLong() != (page.total - offset).coerceIn(0, PAGE_SIZE.toLong()) || + page.items.any { it.taskId <= 0 } || page.items.map { it.taskId }.distinct().size != page.items.size + ) refuse(RepurchaseBatchFailure.INVALID_PAGE) + return page + } + + private fun parseOrdered(items: List): List { + val entries = items.map { Entry(it, parseTime(it.createdAt)) } + val ordered = entries.sortedWith(ENTRY_ORDER) + if (entries != ordered) refuse(RepurchaseBatchFailure.INVALID_ORDER) + return ordered + } + + private fun parseTime(raw: String): Timestamp { + val match = TIMESTAMP.matchEntire(raw) ?: refuse(RepurchaseBatchFailure.INVALID_TIMESTAMP) + val zone = match.groupValues[3].let { if (it == "Z") "+0000" else it.replace(":", "") } + val normalized = match.groupValues[1] + zone + val position = ParsePosition(0) + val parsed = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.ROOT).apply { + isLenient = false + timeZone = TimeZone.getTimeZone("UTC") + }.parse(normalized, position) + if (parsed == null || position.index != normalized.length) refuse(RepurchaseBatchFailure.INVALID_TIMESTAMP) + return Timestamp(parsed.time / 1000, match.groupValues[2].padEnd(9, '0').toInt()) + } + + private data class Entry(val item: PurchaseHistoryItem, val time: Timestamp) + + private data class Timestamp(val seconds: Long, val nanos: Int) : Comparable { + override fun compareTo(other: Timestamp): Int = + seconds.compareTo(other.seconds).takeIf { it != 0 } ?: nanos.compareTo(other.nanos) + + fun moreThanSixtySecondsAfter(older: Timestamp): Boolean { + val secondsApart = seconds - older.seconds + return secondsApart > 60 || (secondsApart == 60L && nanos > older.nanos) + } + } + + companion object { + private const val PAGE_SIZE = 50 + private const val DAYS = 30 + private val TIMESTAMP = Regex("(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2})(?:\\.(\\d{1,9}))?(Z|[+-]\\d{2}:\\d{2})") + private val ENTRY_ORDER = compareByDescending { it.time }.thenByDescending { it.item.taskId } + private fun refuse(reason: RepurchaseBatchFailure): Nothing = throw RepurchaseBatchException(reason) + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/service/RepurchaseBatchTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/service/RepurchaseBatchTest.kt new file mode 100644 index 0000000..e235024 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/service/RepurchaseBatchTest.kt @@ -0,0 +1,191 @@ +package cn.ilapage.goauto.agent.service + +import cn.ilapage.goauto.agent.network.HistoryPage +import cn.ilapage.goauto.agent.network.PurchaseHistoryItem +import org.junit.Assert.* +import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +class RepurchaseBatchTest { + private val now = 1_791_446_400_000L + private fun at(secondsAgo: Long) = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.format(Date(now - secondsAgo * 1000)) + + private fun item(id: Long, secondsAgo: Long = 0, status: String = "failed", retryable: Boolean = true) = PurchaseHistoryItem( + id, status, "synthetic", "synthetic", "synthetic", "", "", "", "", 1, null, "CNY", null, null, + null, null, retryable, if (retryable) null else "不可重试", at(secondsAgo), + ) + + private fun discover(items: List): RepurchaseBatch = RepurchaseBatchDiscovery({ q -> + HistoryPage(items.drop((q.page - 1) * 50).take(50), items.size.toLong(), q.page, 50) + }, { now }).discover() + + private fun refused(reason: RepurchaseBatchFailure, block: () -> Unit) { + try { block(); fail("Expected $reason") } catch (e: RepurchaseBatchException) { assertEquals(reason, e.reason) } + } + + @Test fun sixtySecondsJoinsTransitivelyButSixtyOneStartsOlderBatch() { + val batch = discover(listOf(item(5), item(4, 60), item(3, 120), item(2, 181), item(1, 182))) + assertEquals(listOf(5L, 4L, 3L), batch.items.map { it.taskId }) + assertEquals(at(0), batch.newestCreatedAt) + assertEquals(at(120), batch.oldestCreatedAt) + assertEquals(5L, batch.headTaskId) + } + + @Test fun equalInstantsUseDescendingIdsAndRespectTimezones() { + val batch = discover(listOf(item(3).copy(createdAt = "2026-10-08T08:00:00.123456789+08:00"), + item(2).copy(createdAt = "2026-10-08T00:00:00.123456789Z"), + item(1).copy(createdAt = "2026-10-07T20:00:00.123456789-04:00"))) + assertEquals(listOf(3L, 2L, 1L), batch.items.map { it.taskId }) + } + + @Test fun subMillisecondGapAboveSixtySecondsIsBoundary() { + val batch = discover(listOf(item(2).copy(createdAt = "2026-10-08T00:01:00.000000001Z"), + item(1).copy(createdAt = "2026-10-08T00:00:00Z"))) + assertEquals(listOf(2L), batch.items.map { it.taskId }) + } + + @Test fun readsFullStateHistoryAcrossFiftyItemBoundaryAndRechecksHead() { + val requests = mutableListOf() + val data = (70L downTo 1).map { item(it, 70 - it, if (it % 2 == 0L) "failed" else "order_created") } + val result = RepurchaseBatchDiscovery({ q -> + requests += q + HistoryPage(data.drop((q.page - 1) * 50).take(50), 70, q.page, 50) + }, { now }).discover() + assertEquals(70, result.items.size) + assertEquals(35, result.candidates.size) + assertEquals(listOf(1, 2, 1), requests.map { it.page }) + assertTrue(requests.all { it.days == 30 && it.pageSize == 50 && it.status == null && it.taskNo == null }) + } + + @Test fun overlappingPagesDeduplicateTaskIdsBeforeBoundary() { + val data = (60L downTo 1).map { item(it, if (it >= 10) 60 - it else 300) } + val result = RepurchaseBatchDiscovery({ q -> + val page = if (q.page == 1) data.take(50) else data.drop(49) + HistoryPage(page, 61, q.page, 50) + }, { now }).discover() + assertEquals(51, result.items.size) + assertEquals(51, result.items.map { it.taskId }.distinct().size) + } + + @Test fun headChangeRefusesEvenWhenBoundaryAlreadyFound() { + var calls = 0 + refused(RepurchaseBatchFailure.LIST_CHANGED) { + RepurchaseBatchDiscovery({ q -> + calls++ + HistoryPage(if (calls == 1) listOf(item(2), item(1, 100)) else listOf(item(3), item(2), item(1, 100)), + if (calls == 1) 2 else 3, q.page, 50) + }, { now }).discover() + } + } + + @Test fun invalidDatesAndMissingZonesAreRefused() { + listOf("", "2026-02-30T00:00:00Z", "2026-10-08T00:00:00", "2026-10-08T00:00:00+25:00", "2026-10-08T00:00:00Zjunk").forEach { + refused(RepurchaseBatchFailure.INVALID_TIMESTAMP) { discover(listOf(item(1).copy(createdAt = it))) } + } + } + + @Test fun invertedChronologicalOrIdOrderingIsRefused() { + refused(RepurchaseBatchFailure.INVALID_ORDER) { discover(listOf(item(2, 20), item(1))) } + refused(RepurchaseBatchFailure.INVALID_ORDER) { discover(listOf(item(1), item(2))) } + } + + @Test fun nearThirtyDayCutoffCannotProveBatchComplete() { + val days30 = 30L * 24 * 60 * 60 + listOf(days30, days30 - 60, days30 + 1).forEach { age -> + refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) { discover(listOf(item(1, age))) } + } + assertEquals(1, discover(listOf(item(1, days30 - 61))).items.size) + } + + @Test fun latestSuccessfulBatchDoesNotFallBackToOlderFailures() { + val result = discover(listOf(item(2, status = "order_created"), item(1, 61))) + assertEquals(1, result.items.size) + assertTrue(result.failures.isEmpty()) + assertTrue(result.candidates.isEmpty()) + } + + @Test fun onlyFailedRetryableAreCandidatesAndSecondRoundUsesRemainingFailures() { + val items = (10L downTo 1).map { item(it, status = if (it <= 4) "failed" else "order_created") } + val first = discover(items) + assertEquals(listOf(4L, 3L, 2L, 1L), first.candidates.map { it.taskId }) + val second = discover(items.map { if (it.taskId in 3L..4L) it.copy(status = "order_created") else it }) + assertEquals(listOf(2L, 1L), second.candidates.map { it.taskId }) + val blocked = discover(listOf(item(2, retryable = false), item(1, status = "pending"))) + assertEquals(1, blocked.failures.size) + assertEquals("不可重试", blocked.skips.single().retryDisabledReason) + assertTrue(blocked.candidates.isEmpty()) + } + + @Test fun pageFailureCannotReturnPartOfBatch() { + refused(RepurchaseBatchFailure.PAGE_FAILED) { + RepurchaseBatchDiscovery({ q -> + if (q.page == 2) error("synthetic network failure") + HistoryPage((60L downTo 11).map { item(it, 60 - it) }, 60, q.page, 50) + }, { now }).discover() + } + } + + @Test fun shortPageWithUnseenTotalAndWrongMetadataAreRefused() { + listOf(HistoryPage(listOf(item(1)), 2, 1, 50), HistoryPage(listOf(item(1)), 1, 2, 50), + HistoryPage(listOf(item(1)), 1, 1, 20), HistoryPage(emptyList(), -1, 1, 50)).forEach { page -> + refused(RepurchaseBatchFailure.INVALID_PAGE) { RepurchaseBatchDiscovery({ page }, { now }).discover() } + } + } + + @Test fun maximumPagesCannotReturnTruncatedBatch() { + refused(RepurchaseBatchFailure.PAGE_LIMIT) { + RepurchaseBatchDiscovery({ q -> HistoryPage((60L downTo 11).map { item(it, 60 - it) }, 60, q.page, 50) }, + { now }, maxPages = 1).discover() + } + } + + @Test fun crossPageOrderInversionAndChangedDuplicateTimestampAreRefused() { + listOf(item(1, 1), item(11, 99)).forEach { next -> + refused(RepurchaseBatchFailure.INVALID_ORDER) { + RepurchaseBatchDiscovery({ q -> + HistoryPage(if (q.page == 1) (60L downTo 11).map { item(it, 60 - it) } else listOf(next), + 51, q.page, 50) + }, { now }).discover() + } + } + } + + @Test fun duplicatesCannotMakeAnIncompleteTailLookComplete() { + refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) { + RepurchaseBatchDiscovery({ q -> + HistoryPage(if (q.page == 1) (60L downTo 11).map { item(it, 60 - it) } else listOf(item(11, 49)), + 51, q.page, 50) + }, { now }).discover() + } + } + + @Test fun failedHeadRecheckRefusesTheAlreadyFoundBatch() { + var calls = 0 + refused(RepurchaseBatchFailure.PAGE_FAILED) { + RepurchaseBatchDiscovery({ q -> + if (++calls > 1) error("synthetic network failure") + HistoryPage(listOf(item(1)), 1, q.page, 50) + }, { now }).discover() + } + } + + @Test fun emptyHistoryIsRecheckedAndReturnedEmpty() { + var calls = 0 + val batch = RepurchaseBatchDiscovery({ q -> calls++; HistoryPage(emptyList(), 0, q.page, 50) }, { now }).discover() + assertTrue(batch.items.isEmpty()) + assertNull(batch.headTaskId) + assertEquals(2, calls) + } + + @Test fun immutableSnapshotDoesNotExposeMutableLists() { + val batch = discover(listOf(item(1))) + listOf(batch.items, batch.candidates, batch.failures).forEach { items -> + try { (items as MutableList).clear(); fail("Mutable snapshot") } catch (_: UnsupportedOperationException) { } + } + } +}