feat(android): discover last repurchase batch safely (#367)
This commit is contained in:
@@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user