feat(android): discover last repurchase batch safely (#367)

This commit is contained in:
QiuSW
2026-10-08 17:49:02 +08:00
parent e450ac2898
commit 87192f75d4
2 changed files with 344 additions and 0 deletions
@@ -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<PurchaseHistoryItem>) {
val items: List<PurchaseHistoryItem> = immutable(items)
val candidates: List<PurchaseHistoryItem> = immutable(items.filter { it.status == "failed" && it.retryable })
val failures: List<PurchaseHistoryItem> = immutable(items.filter { it.status == "failed" })
val skips: List<PurchaseHistoryItem> = 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<PurchaseHistoryItem>): List<PurchaseHistoryItem> =
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<PurchaseHistoryItem>,
private val nowMillis: () -> Long = System::currentTimeMillis,
private val maxPages: Int = 200,
) {
init { require(maxPages > 0) }
fun discover(): RepurchaseBatch {
val startedAt = nowMillis()
val seen = linkedMapOf<Long, Entry>()
val batch = mutableListOf<PurchaseHistoryItem>()
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<PurchaseHistoryItem> {
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<PurchaseHistoryItem>): List<Entry> {
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<Timestamp> {
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<Entry> { it.time }.thenByDescending { it.item.taskId }
private fun refuse(reason: RepurchaseBatchFailure): Nothing = throw RepurchaseBatchException(reason)
}
}
@@ -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<PurchaseHistoryItem>): 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<RepurchaseHistoryQuery>()
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) { }
}
}
}