Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba0e48bcea | ||
|
|
edd1cb15df | ||
|
|
482ba3408a | ||
|
|
ca7f768a79 | ||
|
|
72b8b5d4d0 | ||
|
|
290a17ea15 |
+33
@@ -194,6 +194,39 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
return swipeNode(node, direction)
|
||||
}
|
||||
|
||||
/** Backfill never uses ancestor clicks or coordinate/gesture fallbacks. */
|
||||
fun clickBackfill(target: SnapshotNode): Boolean {
|
||||
val page = capture()
|
||||
BackfillPagePolicy.validate(page)
|
||||
val fresh = page.nodes.singleOrNull { it.path == target.path && it.label == target.label &&
|
||||
it.bounds == target.bounds && it.className == target.className } ?: return false
|
||||
val allowed = BackfillPagePolicy.cards(page).any { it.path == fresh.path } ||
|
||||
BackfillPagePolicy.expansion(page)?.path == fresh.path ||
|
||||
(fresh.label == "全部" && page.nodes.any { it.label in setOf("我的订单", "全部订单") })
|
||||
if (!allowed || !BackfillPagePolicy.safe(page, fresh)) return false
|
||||
val root = rootInActiveWindow ?: return false
|
||||
if (root.packageName?.toString() != BackfillPagePolicy.PDD) return false
|
||||
var node = root
|
||||
for (index in fresh.path.split('/').drop(1)) node = node.getChild(index.toInt()) ?: return false
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
if (!node.isClickable || !node.isEnabled || !node.isVisibleToUser ||
|
||||
NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom) != fresh.bounds ||
|
||||
(node.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() } ?: node.contentDescription?.toString()?.trim().orEmpty()) != fresh.label) return false
|
||||
return node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||
}
|
||||
|
||||
fun scrollBackfill(): Boolean {
|
||||
BackfillPagePolicy.validate(capture())
|
||||
val root = rootInActiveWindow ?: return false
|
||||
val candidates = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { if (it.isScrollable && it.isVisibleToUser && it.isEnabled) candidates += it }
|
||||
// Prefer the unique largest vertical viewport. Ambiguous panes fail closed.
|
||||
val areas = candidates.map { it to Rect().also(it::getBoundsInScreen) }
|
||||
val maxArea = areas.maxOfOrNull { it.second.width().toLong() * it.second.height() } ?: return false
|
||||
val target = areas.filter { it.second.width().toLong() * it.second.height() == maxArea }.singleOrNull() ?: return false
|
||||
return target.first.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
|
||||
}
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
val root = rootInActiveWindow ?: return UiSnapshot(null, null, emptyList())
|
||||
val rootPackage = root.packageName?.toString()
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import java.math.BigInteger
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/** Runtime evidence only: a sorted prefix cannot prove the unvisited tail is sorted. */
|
||||
class OrderBackfillWindow(days: String, val confirmedAt: Long) {
|
||||
val cutoff: Long
|
||||
var checked = 0
|
||||
private set
|
||||
var nonDescending = false
|
||||
private set
|
||||
private var previous: Long? = null
|
||||
private var missingTime = false
|
||||
private var timed = 0
|
||||
|
||||
init {
|
||||
require(days.matches(Regex("[0-9]+")) && BigInteger(days) > BigInteger.ZERO)
|
||||
cutoff = BigInteger.valueOf(confirmedAt).subtract(BigInteger(days).multiply(BigInteger.valueOf(86_400_000)))
|
||||
.max(BigInteger.valueOf(Long.MIN_VALUE)).toLong()
|
||||
}
|
||||
|
||||
fun observe(time: Long?): Boolean {
|
||||
checked++
|
||||
if (time == null) missingTime = true else {
|
||||
if (previous != null && time > previous!!) nonDescending = true
|
||||
previous = time
|
||||
timed++
|
||||
}
|
||||
// Check at least five details before using a sampled ordering assumption.
|
||||
return !nonDescending && !missingTime && timed >= ORDERING_SAMPLE && time != null && time < cutoff
|
||||
}
|
||||
|
||||
fun includes(time: Long?): Boolean = time == null || time in cutoff..confirmedAt
|
||||
|
||||
companion object {
|
||||
const val ORDERING_SAMPLE = 5
|
||||
const val MAX_ORDERS = 200
|
||||
const val MAX_DURATION_MS = 10 * 60_000L
|
||||
const val UNORDERED = "列表非严格倒序,已改为有界扫描,可能未覆盖全部"
|
||||
}
|
||||
}
|
||||
|
||||
data class BackfillItem(val addressSuffix: String, val pddOrderNo: String, val orderSubmittedAt: String?)
|
||||
data class BackfillDetail(val item: BackfillItem?, val timeMillis: Long?)
|
||||
|
||||
/** Per-detail accumulator. Never retains raw text, addresses, names or phone numbers. */
|
||||
class BackfillDetailReader(private val zone: TimeZone = TimeZone.getDefault()) {
|
||||
private val suffixes = mutableSetOf<String>()
|
||||
private val orders = mutableSetOf<String>()
|
||||
private val times = mutableSetOf<Long>()
|
||||
|
||||
fun accept(text: String) {
|
||||
SUFFIX.findAll(text).forEach { suffixes += it.value }
|
||||
ORDER_NO.findAll(text).forEach { orders += it.groupValues[1] }
|
||||
ORDER_TIME.findAll(text).forEach { match -> parseTime(match.groupValues[1])?.let(times::add) }
|
||||
}
|
||||
|
||||
fun finish(): BackfillDetail {
|
||||
val time = times.singleOrNull()
|
||||
val item = if (suffixes.size == 1 && orders.size == 1 && times.size <= 1) {
|
||||
BackfillItem(suffixes.single(), orders.single(), time?.let {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.ROOT).apply { timeZone = zone }.format(it)
|
||||
})
|
||||
} else null
|
||||
suffixes.clear()
|
||||
orders.clear()
|
||||
times.clear()
|
||||
return BackfillDetail(item, time)
|
||||
}
|
||||
|
||||
private fun parseTime(raw: String): Long? {
|
||||
val normalized = raw.replace('年', '-').replace('月', '-').replace("日", "").replace('/', '-').replace('.', '-')
|
||||
val pattern = if (normalized.count { it == ':' } == 2) "yyyy-M-d H:mm:ss" else "yyyy-M-d H:mm"
|
||||
val position = java.text.ParsePosition(0)
|
||||
val date = SimpleDateFormat(pattern, Locale.ROOT).apply { isLenient = false; timeZone = zone }.parse(normalized, position)
|
||||
return date?.time?.takeIf { position.index == normalized.length }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ORDER_NO = Regex("(?:订单编号|订单号)\\s*[::]?\\s*([A-Za-z0-9-]{6,64})")
|
||||
val ORDER_TIME = Regex("(?:下单时间|创建时间)\\s*[::]?\\s*(20\\d{2}[-/.年]\\d{1,2}[-/.月]\\d{1,2}日?\\s+\\d{1,2}:\\d{2}(?::\\d{2})?)")
|
||||
private val SUFFIX = Regex("_cg[1-9][0-9]*(?![0-9A-Za-z_0-9])")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
object BackfillPagePolicy {
|
||||
const val PDD = "com.xunmeng.pinduoduo"
|
||||
val forbidden = listOf("确认收货", "申请退款", "催发货", "去支付", "立即支付", "提交订单", "付款", "退款", "取消订单", "再次购买", "删除订单")
|
||||
private val risk = listOf("验证码", "安全验证", "人机验证", "登录", "账号异常", "风险验证", "拖动滑块")
|
||||
fun validate(page: UiSnapshot) {
|
||||
require(page.packageName == PDD && page.activityName?.startsWith(PDD) == true) { "PDD 页面身份不符" }
|
||||
require(page.nodes.none { it.visible && risk.any { word -> it.label.contains(word) } }) { "遇到登录或安全验证,已停止" }
|
||||
}
|
||||
private fun subtree(page: UiSnapshot, node: SnapshotNode) = page.nodes.filter { it.path == node.path || it.path.startsWith(node.path + "/") }
|
||||
fun safe(page: UiSnapshot, node: SnapshotNode): Boolean = node.visible && node.enabled && node.clickable &&
|
||||
node.bounds.width > 0 && node.bounds.height > 0 &&
|
||||
subtree(page, node).none { child -> forbidden.any { child.label.contains(it) } } &&
|
||||
page.nodes.none { other -> other.visible && forbidden.any { other.label.contains(it) } &&
|
||||
other.bounds.left < node.bounds.right && other.bounds.right > node.bounds.left &&
|
||||
other.bounds.top < node.bounds.bottom && other.bounds.bottom > node.bounds.top }
|
||||
|
||||
fun list(page: UiSnapshot): Boolean = page.nodes.any { it.visible && it.label == "全部" && it.selected } &&
|
||||
page.nodes.any { it.visible && it.label in setOf("我的订单", "全部订单") }
|
||||
fun detail(page: UiSnapshot): Boolean = page.nodes.any { it.visible && (it.label == "订单详情" || it.label.contains("订单编号")) }
|
||||
fun expansion(page: UiSnapshot): SnapshotNode? {
|
||||
if (!detail(page) || page.nodes.none { it.visible && it.label.contains("订单编号") }) return null
|
||||
val order = page.nodes.first { it.visible && it.label.contains("订单编号") }
|
||||
val snapshot = page.nodes.filter { it.visible && it.label == "商品快照" }.singleOrNull() ?: return null
|
||||
return page.nodes.filter { it.label == "展开" && safe(page, it) && it.bounds.top >= order.bounds.top &&
|
||||
it.bounds.top < snapshot.bounds.bottom && it.bounds.bottom > snapshot.bounds.top }.singleOrNull()
|
||||
}
|
||||
fun cards(page: UiSnapshot): List<SnapshotNode> {
|
||||
if (!list(page)) return emptyList()
|
||||
return page.nodes.filter { node ->
|
||||
if (!safe(page, node)) return@filter false
|
||||
if (node.label in setOf("订单详情", "查看详情")) return@filter true
|
||||
val children = subtree(page, node)
|
||||
val product = children.any { it.className?.endsWith("ImageView") == true } && children.any { it.label.length >= 4 }
|
||||
val parent = page.nodes.firstOrNull { it.path == node.parentPath } ?: return@filter false
|
||||
val context = subtree(page, parent)
|
||||
product && context.any { it.label in setOf("查看物流", "确认收货", "去支付", "待发货", "待收货", "交易成功", "再次购买") }
|
||||
}.sortedBy { it.bounds.top }.let { candidates ->
|
||||
candidates.filter { node -> candidates.none { it !== node && it.path.startsWith(node.path + "/") } }
|
||||
}
|
||||
}
|
||||
fun fingerprint(page: UiSnapshot, card: SnapshotNode): String {
|
||||
val labels = subtree(page, card).joinToString("|") { it.label }
|
||||
return MessageDigest.getInstance("SHA-256").digest(labels.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
interface BackfillDriver {
|
||||
fun openOrders()
|
||||
fun capture(): UiSnapshot
|
||||
fun click(node: SnapshotNode): Boolean
|
||||
fun scroll(): Boolean
|
||||
fun back(): Boolean
|
||||
fun pause()
|
||||
}
|
||||
|
||||
class OrderBackfillScanner(
|
||||
private val driver: BackfillDriver,
|
||||
private val window: OrderBackfillWindow,
|
||||
private val checkActive: () -> Unit,
|
||||
private val submit: (BackfillItem) -> Unit,
|
||||
private val progress: (Int) -> Unit,
|
||||
) {
|
||||
fun scan(): String {
|
||||
checkActive()
|
||||
driver.openOrders()
|
||||
driver.pause()
|
||||
var page = read()
|
||||
if (!BackfillPagePolicy.list(page)) {
|
||||
val tab = page.nodes.filter { it.label == "全部" && BackfillPagePolicy.safe(page, it) }.singleOrNull()
|
||||
check(page.nodes.any { it.label in setOf("我的订单", "全部订单") } && tab != null) { "未识别我的订单-全部" }
|
||||
act { driver.click(tab) }
|
||||
page = read()
|
||||
check(BackfillPagePolicy.list(page)) { "无法确认全部订单标签" }
|
||||
}
|
||||
if (page.nodes.any { it.visible && it.label == "暂无订单" }) return "扫描完成,未发现订单"
|
||||
val seenCards = mutableSetOf<String>()
|
||||
val seenOrders = mutableSetOf<String>()
|
||||
var noProgress = 0
|
||||
while (window.checked < OrderBackfillWindow.MAX_ORDERS) {
|
||||
checkActive()
|
||||
check(BackfillPagePolicy.list(page)) { "返回后未识别全部订单列表" }
|
||||
val card = BackfillPagePolicy.cards(page).firstOrNull { BackfillPagePolicy.fingerprint(page, it) !in seenCards }
|
||||
if (card == null) {
|
||||
if (++noProgress >= 3) return finish("列表无进展或卡片无法安全识别,未完整扫描")
|
||||
checkActive()
|
||||
if (!driver.scroll()) return finish("列表滚动结束或不可滚动,未完整扫描")
|
||||
driver.pause()
|
||||
page = read()
|
||||
continue
|
||||
}
|
||||
noProgress = 0
|
||||
seenCards += BackfillPagePolicy.fingerprint(page, card)
|
||||
act { driver.click(card) }
|
||||
var detail = read()
|
||||
check(BackfillPagePolicy.detail(detail)) { "点击后未识别订单详情" }
|
||||
val reader = BackfillDetailReader()
|
||||
var expanded = false
|
||||
// Limited detail scrolling; only this accumulator associates fields across these frames.
|
||||
for (step in 0 until 6) {
|
||||
reader.accept(detail.nodes.filter { it.visible }.joinToString("\n") { it.label })
|
||||
val expand = if (expanded) null else BackfillPagePolicy.expansion(detail)
|
||||
if (expand != null) {
|
||||
act { driver.click(expand) }
|
||||
expanded = true
|
||||
} else {
|
||||
checkActive()
|
||||
if (!driver.scroll()) break
|
||||
driver.pause()
|
||||
}
|
||||
detail = read()
|
||||
check(BackfillPagePolicy.detail(detail)) { "滚动后无法确认订单详情,未完整扫描" }
|
||||
}
|
||||
reader.accept(detail.nodes.filter { it.visible }.joinToString("\n") { it.label })
|
||||
val found = reader.finish()
|
||||
val stopForTime = window.observe(found.timeMillis)
|
||||
progress(window.checked)
|
||||
found.item?.takeIf { window.includes(found.timeMillis) && seenOrders.add(it.pddOrderNo) }?.let(submit)
|
||||
if (stopForTime) return "已达指定天数(已读序列倒序);未完整扫描,后续列表时序未经验证"
|
||||
act { driver.back() }
|
||||
page = read()
|
||||
}
|
||||
return finish("达到 200 单内部上限,未完整扫描")
|
||||
}
|
||||
|
||||
private fun finish(reason: String): String = reason + if (window.nonDescending) "\n${OrderBackfillWindow.UNORDERED}" else ""
|
||||
|
||||
private fun read(): UiSnapshot {
|
||||
checkActive()
|
||||
return driver.capture().also(BackfillPagePolicy::validate)
|
||||
}
|
||||
private fun act(action: () -> Boolean) {
|
||||
checkActive()
|
||||
check(action()) { "安全页面操作失败,未完整扫描" }
|
||||
driver.pause()
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,9 @@ class AgentApiException(
|
||||
) : Exception(message)
|
||||
|
||||
class AgentApiClient(private val serverUrl: String) {
|
||||
fun backfillOrders(requestId: String, items: List<cn.ilapage.goauto.agent.automation.BackfillItem>, token: String): List<BackfillResult> =
|
||||
parseBackfillResults(post("/api/agent/v1/purchase-tasks/order-backfill", backfillPayload(requestId, items), token))
|
||||
|
||||
fun testConnection() {
|
||||
requireNotNull(request("GET", "/api/v1/health", null, null))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package cn.ilapage.goauto.agent.network
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.BackfillItem
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
|
||||
data class BackfillResult(
|
||||
val index: Int, val taskId: Long, val result: String, val code: String,
|
||||
val status: String, val statusVersion: Long, val pddOrderNo: String?,
|
||||
val orderSubmittedAt: String?, val timeSource: String,
|
||||
) {
|
||||
val success get() = result in setOf("backfilled", "already_backfilled")
|
||||
val needsReview get() = !success && code != "INTERNAL_ERROR" && code != "NETWORK_ERROR"
|
||||
fun display(): String = if (success) {
|
||||
val source = when (timeSource) {
|
||||
"page" -> "页面下单时间"
|
||||
"irreversible_at" -> "估算时间(提交订单时刻)"
|
||||
else -> "已有时间(来源未知)"
|
||||
}
|
||||
"CG-$taskId:${if (result == "already_backfilled") "已回填" else "成功"};$source ${orderSubmittedAt.orEmpty()}"
|
||||
} else "${if (taskId > 0) "CG-$taskId" else "条目 ${index + 1}"}:${if (needsReview) "需人工检查" else "重试耗尽,未确认"}($code)"
|
||||
}
|
||||
|
||||
internal fun backfillPayload(requestId: String, items: List<BackfillItem>): JSONObject {
|
||||
require(items.size in 1..50)
|
||||
return JSONObject().put("requestId", requestId).put("items", JSONArray().apply {
|
||||
items.forEach { item -> put(JSONObject().put("addressSuffix", item.addressSuffix).put("pddOrderNo", item.pddOrderNo).apply {
|
||||
item.orderSubmittedAt?.let { put("orderSubmittedAt", it) }
|
||||
}) }
|
||||
})
|
||||
}
|
||||
|
||||
internal fun parseBackfillResults(data: JSONObject): List<BackfillResult> {
|
||||
val items = data.getJSONArray("items")
|
||||
return (0 until items.length()).map { index -> items.getJSONObject(index).let {
|
||||
BackfillResult(it.getInt("index"), it.optLong("taskId"), it.getString("result"), it.getString("code"),
|
||||
it.optString("status"), it.optLong("statusVersion"),
|
||||
if (it.isNull("pddOrderNo")) null else it.optString("pddOrderNo"),
|
||||
if (it.isNull("orderSubmittedAt")) null else it.optString("orderSubmittedAt"), it.optString("timeSource"))
|
||||
} }
|
||||
}
|
||||
|
||||
/** Separate from the purchase outbox: only transport failures / INTERNAL_ERROR retry, three attempts total. */
|
||||
class OrderBackfillUpload(
|
||||
private val submit: (String, List<BackfillItem>) -> List<BackfillResult>,
|
||||
private val checkActive: () -> Unit,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
) {
|
||||
fun upload(items: List<BackfillItem>, confirmed: (BackfillResult) -> Unit) {
|
||||
var pending = items.mapIndexed { index, item -> index to item }
|
||||
var requestId = UUID.randomUUID().toString()
|
||||
repeat(3) { attempt ->
|
||||
checkActive()
|
||||
val results = try {
|
||||
submit(requestId, pending.map { it.second }).also { values ->
|
||||
check(values.size == pending.size && values.map { it.index }.toSet() == pending.indices.toSet())
|
||||
values.filter { it.success }.forEach { value ->
|
||||
val input = pending[value.index].second
|
||||
check(value.taskId.toString() == input.addressSuffix.removePrefix("_cg") && value.pddOrderNo == input.pddOrderNo)
|
||||
check(value.status.isNotBlank())
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
val code = when (error) {
|
||||
is AgentApiException -> error.code
|
||||
is IOException -> "NETWORK_ERROR"
|
||||
else -> throw error
|
||||
}
|
||||
if (code in setOf("INTERNAL_ERROR", "NETWORK_ERROR") && attempt < 2) {
|
||||
pause((attempt + 1) * 1_000L)
|
||||
return@repeat // same request ID for an unknown transport result
|
||||
}
|
||||
pending.forEach { (index, _) -> confirmed(BackfillResult(index, 0, "failed", code, "", 0, null, null, "")) }
|
||||
return
|
||||
}
|
||||
// A completed HTTP response is evidence, even when cancellation arrived while waiting.
|
||||
val retry = mutableListOf<Pair<Int, BackfillItem>>()
|
||||
results.forEach { result ->
|
||||
val original = pending[result.index]
|
||||
if (!result.success && result.code == "INTERNAL_ERROR" && attempt < 2) retry += original
|
||||
else confirmed(result.copy(index = original.first))
|
||||
}
|
||||
if (retry.isEmpty()) return
|
||||
pending = retry
|
||||
requestId = UUID.randomUUID().toString() // payload changed after partial acknowledgement
|
||||
checkActive()
|
||||
pause((attempt + 1) * 1_000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,32 @@ import org.json.JSONObject
|
||||
class TaskHistoryCache(context: Context) {
|
||||
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
|
||||
/** Only acknowledged server facts may overwrite cached task facts. Never store scanned candidates. */
|
||||
fun applyBackfill(result: cn.ilapage.goauto.agent.network.BackfillResult, environment: String) = synchronized(BACKFILL_LOCK) {
|
||||
if (!result.success) {
|
||||
if (result.needsReview) check(preferences.edit().putString("backfill_review_${result.taskId}_${result.code}",
|
||||
JSONObject().put("taskId", result.taskId).put("code", result.code).put("environment", environment).toString()).commit())
|
||||
return@synchronized
|
||||
}
|
||||
val values = JSONArray(preferences.getString(PURCHASE, "[]"))
|
||||
for (index in 0 until values.length()) {
|
||||
applyBackfillFacts(values.getJSONObject(index), result)
|
||||
}
|
||||
// Also retain the small acknowledged summary when this task is absent from downloaded history.
|
||||
val confirmed = JSONObject().put("taskId", result.taskId).put("status", result.status)
|
||||
.put("statusVersion", result.statusVersion).putNullable("pddOrderNo", result.pddOrderNo)
|
||||
.putNullable("orderSubmittedAt", result.orderSubmittedAt).put("timeSource", result.timeSource)
|
||||
.put("environment", environment)
|
||||
check(preferences.edit().putString(PURCHASE, values.toString())
|
||||
.putString("backfill_confirmed_${result.taskId}", confirmed.toString()).commit())
|
||||
}
|
||||
|
||||
fun backfillTimeSource(taskId: Long, environment: String): String? {
|
||||
val raw = preferences.getString("backfill_confirmed_$taskId", null) ?: return null
|
||||
val confirmed = runCatching { JSONObject(raw) }.getOrNull() ?: return null
|
||||
return confirmed.optString("timeSource").takeIf { confirmed.optString("environment") == environment }
|
||||
}
|
||||
|
||||
fun saveCollection(days: Int, items: List<CollectionHistoryItem>) = save(COLLECTION, days, JSONArray().apply {
|
||||
items.forEach { item -> put(JSONObject()
|
||||
.put("taskId", item.taskId).put("attemptNumber", item.attemptNumber).put("status", item.status).put("source", item.source).put("goodsId", item.goodsId)
|
||||
@@ -90,9 +116,18 @@ class TaskHistoryCache(context: Context) {
|
||||
private fun JSONObject.nullableLong(key: String): Long? = if (isNull(key)) null else optLong(key)
|
||||
|
||||
private companion object {
|
||||
val BACKFILL_LOCK = Any()
|
||||
const val PREFERENCES = "goauto_task_history_cache"
|
||||
const val COLLECTION = "collection"
|
||||
const val PURCHASE = "purchase"
|
||||
const val PAGE_SIZE = 20
|
||||
}
|
||||
}
|
||||
|
||||
internal fun applyBackfillFacts(task: JSONObject, result: cn.ilapage.goauto.agent.network.BackfillResult) {
|
||||
if (!result.success || task.optLong("taskId") != result.taskId) return
|
||||
task.put("status", result.status).put("pddOrderNo", result.pddOrderNo ?: JSONObject.NULL)
|
||||
.put("orderSubmittedAt", result.orderSubmittedAt ?: JSONObject.NULL)
|
||||
.put("errorCode", JSONObject.NULL).put("errorMessage", JSONObject.NULL)
|
||||
.put("retryable", false).put("retryDisabledReason", JSONObject.NULL)
|
||||
}
|
||||
|
||||
+114
-2
@@ -75,6 +75,7 @@ class AgentForegroundService : Service() {
|
||||
private val taskExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
private val diagnosticExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
private val taskMutex = TaskExecutionMutex()
|
||||
private val backfillGuard = OrderBackfillGuard(taskMutex)
|
||||
private val runningTaskId = AtomicReference<Long?>(null)
|
||||
private val working = AtomicBoolean(false)
|
||||
private val manualCheckRequested = AtomicBoolean(false)
|
||||
@@ -132,6 +133,14 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_BACKFILL_STOP) {
|
||||
backfillGuard.cancelled.set(true)
|
||||
return START_STICKY
|
||||
}
|
||||
if (intent?.action == ACTION_BACKFILL_START) {
|
||||
requestOrderBackfill(intent.getStringExtra("days").orEmpty(), intent.getLongExtra("confirmedAt", 0))
|
||||
return START_STICKY
|
||||
}
|
||||
if (intent?.action == ACTION_RECONNECT) registeredThisProcess.set(false)
|
||||
if (intent?.action == ACTION_CHECK_NOW) manualCheckRequested.set(true)
|
||||
if (intent?.action == ACTION_CURRENT_PAGE_COLLECTION) {
|
||||
@@ -147,6 +156,7 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
backfillGuard.cancelled.set(true)
|
||||
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
|
||||
cancelIdleReturn("服务已停止")
|
||||
collectionCooldownFuture.getAndSet(null)?.cancel(false)
|
||||
@@ -165,7 +175,7 @@ class AgentForegroundService : Service() {
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun triggerSync() {
|
||||
if (!working.compareAndSet(false, true)) return
|
||||
if (!synchronized(taskMutex) { working.compareAndSet(false, true) }) return
|
||||
executor.execute {
|
||||
val manualCheck = manualCheckRequested.getAndSet(false)
|
||||
try {
|
||||
@@ -307,6 +317,98 @@ class AgentForegroundService : Service() {
|
||||
})
|
||||
}
|
||||
|
||||
private fun publishBackfill(state: OrderBackfillState) {
|
||||
backfillState = state
|
||||
sendBroadcast(Intent(ACTION_BACKFILL_STATE).setPackage(packageName))
|
||||
updateNotification(if (state.running) "订单回填 · 已检查 ${state.checked}" else "订单回填已停止,请查看采购页结果")
|
||||
}
|
||||
|
||||
private fun requestOrderBackfill(days: String, confirmedAt: Long) {
|
||||
// Refuse while polling/dispatch is in flight too: never queue behind another PDD operation.
|
||||
val acquired = synchronized(taskMutex) {
|
||||
!working.get() && purchaseStore.activeTaskId() == null && backfillGuard.tryAcquire()
|
||||
}
|
||||
if (!acquired) {
|
||||
android.widget.Toast.makeText(this, "设备忙碌,请稍后操作", android.widget.Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
try {
|
||||
val window = cn.ilapage.goauto.agent.automation.OrderBackfillWindow(days, confirmedAt)
|
||||
val server = settingsStore.serverUrl()
|
||||
val credentials = identityStore.credentials() ?: error("设备尚未注册")
|
||||
val accessibility = GoAutoAccessibilityService.instance ?: error("请先启用无障碍服务")
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
cancelIdleReturn("人工订单回填")
|
||||
publishBackfill(OrderBackfillState(running = true, message = "正在检查订单列表时序…"))
|
||||
taskExecutor.execute {
|
||||
var state = backfillState
|
||||
fun checkActive() {
|
||||
check(!backfillGuard.cancelled.get() && !Thread.currentThread().isInterrupted) { "用户停止,未完整扫描" }
|
||||
check(SystemClock.elapsedRealtime() - started < cn.ilapage.goauto.agent.automation.OrderBackfillWindow.MAX_DURATION_MS) { "达到 10 分钟内部上限,未完整扫描" }
|
||||
check(settingsStore.serverUrl() == server && identityStore.credentials() == credentials) { "服务器或设备身份已变化,未完整扫描" }
|
||||
}
|
||||
try {
|
||||
acquireTaskWakeLock()
|
||||
val api = AgentApiClient(server)
|
||||
val uploader = cn.ilapage.goauto.agent.network.OrderBackfillUpload(
|
||||
submit = { id, items -> api.backfillOrders(id, items, credentials.token) },
|
||||
checkActive = ::checkActive,
|
||||
)
|
||||
val driver = object : cn.ilapage.goauto.agent.automation.BackfillDriver {
|
||||
override fun openOrders() {
|
||||
checkActive()
|
||||
startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://mobile.yangkeduo.com/orders.html"))
|
||||
.setPackage(cn.ilapage.goauto.agent.automation.BackfillPagePolicy.PDD).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
override fun capture() = accessibility.capture()
|
||||
override fun click(node: cn.ilapage.goauto.agent.automation.SnapshotNode): Boolean { checkActive(); return accessibility.clickBackfill(node) }
|
||||
override fun scroll(): Boolean { checkActive(); return accessibility.scrollBackfill() }
|
||||
override fun back(): Boolean {
|
||||
checkActive()
|
||||
cn.ilapage.goauto.agent.automation.BackfillPagePolicy.validate(capture())
|
||||
return accessibility.back()
|
||||
}
|
||||
override fun pause() { repeat(10) { checkActive(); Thread.sleep(100) } }
|
||||
}
|
||||
val reason = cn.ilapage.goauto.agent.automation.OrderBackfillScanner(driver, window, ::checkActive,
|
||||
submit = { item -> uploader.upload(listOf(item)) { result ->
|
||||
state = state.copy(success = state.success + if (result.result == "backfilled") 1 else 0,
|
||||
already = state.already + if (result.result == "already_backfilled") 1 else 0,
|
||||
failed = state.failed + if (!result.success) 1 else 0,
|
||||
evidence = state.evidence + result.display())
|
||||
// The frozen API receives all submissions. Never write an old environment's response into a new cache.
|
||||
if (settingsStore.serverUrl() == server && identityStore.credentials() == credentials) {
|
||||
cn.ilapage.goauto.agent.persistence.TaskHistoryCache(this).applyBackfill(result, "$server|${credentials.deviceId}")
|
||||
}
|
||||
publishBackfill(state)
|
||||
check(result.success || result.needsReview) { "网络或服务端瞬时错误重试耗尽,未完整扫描" }
|
||||
} },
|
||||
progress = { checked ->
|
||||
state = state.copy(checked = checked, message = if (window.nonDescending) cn.ilapage.goauto.agent.automation.OrderBackfillWindow.UNORDERED else "正在扫描(已读 $checked 单)…")
|
||||
publishBackfill(state)
|
||||
}).scan()
|
||||
state = state.copy(message = reason)
|
||||
} catch (error: Exception) {
|
||||
// Never echo raw page text, HTTP bodies or credentials in UI/logs.
|
||||
val reason = if (error is IllegalStateException || error is IllegalArgumentException) error.message else null
|
||||
val message = reason?.takeIf { it.length < 100 } ?: "网络或页面异常"
|
||||
state = state.copy(message = message + if (message.contains("未完整扫描")) "" else ",未完整扫描")
|
||||
} finally {
|
||||
val warning = if (window.nonDescending && !state.message.contains(cn.ilapage.goauto.agent.automation.OrderBackfillWindow.UNORDERED))
|
||||
"\n${cn.ilapage.goauto.agent.automation.OrderBackfillWindow.UNORDERED}" else ""
|
||||
try {
|
||||
publishBackfill(state.copy(running = false, message = state.message + warning))
|
||||
} finally {
|
||||
try { releaseTaskWakeLock() } finally { backfillGuard.release() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
backfillGuard.release()
|
||||
publishBackfill(OrderBackfillState(message = "回填未启动,请检查天数、设备连接与无障碍服务"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestCurrentPageCollection(
|
||||
requestId: String,
|
||||
replacementOriginType: String?,
|
||||
@@ -1038,10 +1140,15 @@ class AgentForegroundService : Service() {
|
||||
@Suppress("DEPRECATION")
|
||||
Notification.Builder(this)
|
||||
}
|
||||
if (backfillState.running) {
|
||||
val stop = PendingIntent.getService(this, 242, Intent(this, AgentForegroundService::class.java).setAction(ACTION_BACKFILL_STOP),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
builder.addAction(Notification.Action.Builder(null, "停止回填", stop).build())
|
||||
}
|
||||
return builder
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(content)
|
||||
.setContentText(if (backfillState.running) "订单回填 · 已检查 ${backfillState.checked}" else content)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
@@ -1067,6 +1174,11 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_BACKFILL_START = "cn.ilapage.goauto.agent.BACKFILL_START"
|
||||
const val ACTION_BACKFILL_STOP = "cn.ilapage.goauto.agent.BACKFILL_STOP"
|
||||
const val ACTION_BACKFILL_STATE = "cn.ilapage.goauto.agent.BACKFILL_STATE"
|
||||
@Volatile var backfillState = OrderBackfillState()
|
||||
private set
|
||||
const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT"
|
||||
const val ACTION_CHECK_NOW = "cn.ilapage.goauto.agent.CHECK_NOW"
|
||||
const val ACTION_CHECK_RESULT = "cn.ilapage.goauto.agent.CHECK_RESULT"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class OrderBackfillGuard(private val mutex: TaskExecutionMutex) {
|
||||
private val active = AtomicBoolean(false)
|
||||
val cancelled = AtomicBoolean(false)
|
||||
fun tryAcquire(): Boolean {
|
||||
if (!active.compareAndSet(false, true)) return false
|
||||
if (!mutex.tryAcquire(RESERVATION)) {
|
||||
active.set(false)
|
||||
return false
|
||||
}
|
||||
cancelled.set(false)
|
||||
return true
|
||||
}
|
||||
fun release() {
|
||||
mutex.release(RESERVATION)
|
||||
active.set(false)
|
||||
}
|
||||
companion object { const val RESERVATION = Long.MAX_VALUE - 1 }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
data class OrderBackfillState(
|
||||
val running: Boolean = false,
|
||||
val checked: Int = 0,
|
||||
val success: Int = 0,
|
||||
val already: Int = 0,
|
||||
val failed: Int = 0,
|
||||
val message: String = "",
|
||||
val evidence: List<String> = emptyList(),
|
||||
) {
|
||||
fun text(): String = "已检查 $checked · 成功 $success · 已回填 $already · 冲突/失败 $failed\n$message" +
|
||||
if (evidence.isEmpty()) "" else "\n" + evidence.joinToString("\n")
|
||||
}
|
||||
@@ -141,8 +141,16 @@ class TaskHistoryFragment : Fragment() {
|
||||
private val imageLoader = HistoryImageLoader()
|
||||
private val imageRequests = mutableListOf<HistoryImageRequest>()
|
||||
private var currentPageReceiverRegistered = false
|
||||
private var backfillPanel: LinearLayout? = null
|
||||
private val currentPageReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == AgentForegroundService.ACTION_BACKFILL_STATE) {
|
||||
renderBackfill()
|
||||
if (!collection && isResumed && !AgentForegroundService.backfillState.running) {
|
||||
detailState.taskId?.let(::loadPurchaseDetail) ?: load()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (intent?.action != AgentForegroundService.ACTION_CURRENT_PAGE_RESULT) return
|
||||
val message = intent.getStringExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_MESSAGE).orEmpty()
|
||||
val taskId = intent.getLongExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_TASK_ID, 0L)
|
||||
@@ -182,6 +190,11 @@ class TaskHistoryFragment : Fragment() {
|
||||
pageColumn = context.column()
|
||||
pageColumn.addView(context.screenTitle(if (collection) "采集记录" else "采购记录"))
|
||||
pageColumn.addView(buildSearch())
|
||||
if (!collection) {
|
||||
backfillPanel = context.column(0)
|
||||
pageColumn.addView(backfillPanel)
|
||||
renderBackfill()
|
||||
}
|
||||
pageColumn.addView(buildFilters())
|
||||
resultColumn = context.column(0).apply { setPadding(0, context.dp(12), 0, 0) }
|
||||
pageColumn.addView(resultColumn, resultColumn.fullWidth())
|
||||
@@ -207,6 +220,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
backfillPanel = null
|
||||
requestGeneration++
|
||||
cancelImageRequests()
|
||||
super.onDestroyView()
|
||||
@@ -273,6 +287,14 @@ class TaskHistoryFragment : Fragment() {
|
||||
contentDescription = "采集当前拼多多商品"
|
||||
setOnClickListener { confirmCurrentPageCollection() }
|
||||
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
|
||||
} else {
|
||||
row.addView(MaterialButton(context).apply {
|
||||
text = "回填"
|
||||
textSize = 14f
|
||||
minimumHeight = context.dp(48)
|
||||
contentDescription = "回填拼多多订单号和下单时间"
|
||||
setOnClickListener { showBackfillInput() }
|
||||
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
|
||||
}
|
||||
addView(row, row.fullWidth())
|
||||
}).apply {
|
||||
@@ -280,6 +302,75 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBackfillInput() {
|
||||
if (AgentForegroundService.backfillState.running) {
|
||||
toast("设备忙碌,请稍后操作")
|
||||
return
|
||||
}
|
||||
val context = requireContext()
|
||||
val input = TextInputEditText(context).apply {
|
||||
setText("2")
|
||||
inputType = android.text.InputType.TYPE_CLASS_NUMBER
|
||||
minimumHeight = context.dp(48)
|
||||
contentDescription = "回填天数"
|
||||
selectAll()
|
||||
}
|
||||
val field = TextInputLayout(context).apply {
|
||||
hint = "天数"
|
||||
helperText = "从确认时刻往前 N×24 小时"
|
||||
addView(input)
|
||||
}
|
||||
val dialog = MaterialAlertDialogBuilder(context).setTitle("回填订单")
|
||||
.setView(context.cardColumn().apply { addView(field) })
|
||||
.setNegativeButton("取消", null).setPositiveButton("确认", null).create()
|
||||
dialog.setOnShowListener {
|
||||
dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||
val days = input.text.toString().trim()
|
||||
val now = System.currentTimeMillis()
|
||||
if (runCatching { cn.ilapage.goauto.agent.automation.OrderBackfillWindow(days, now) }.isFailure) {
|
||||
field.error = "请输入正整数天数"
|
||||
return@setOnClickListener
|
||||
}
|
||||
val intent = Intent(context, AgentForegroundService::class.java)
|
||||
.setAction(AgentForegroundService.ACTION_BACKFILL_START).putExtra("days", days).putExtra("confirmedAt", now)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent) else context.startService(intent)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun renderBackfill() {
|
||||
val panel = backfillPanel ?: return
|
||||
val context = context ?: return
|
||||
val state = AgentForegroundService.backfillState
|
||||
panel.removeAllViews()
|
||||
if (state.message.isBlank()) return
|
||||
panel.addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label(if (state.running) "正在回填订单" else "回填结果", 16f))
|
||||
addView(context.label(state.copy(evidence = emptyList()).text(), 14f))
|
||||
if (state.running) {
|
||||
addView(MaterialButton(context).apply {
|
||||
text = "停止"
|
||||
minimumHeight = context.dp(48)
|
||||
setOnClickListener {
|
||||
context.startService(Intent(context, AgentForegroundService::class.java).setAction(AgentForegroundService.ACTION_BACKFILL_STOP))
|
||||
isEnabled = false
|
||||
text = "正在停止…"
|
||||
}
|
||||
})
|
||||
}
|
||||
if (state.evidence.isNotEmpty()) addView(MaterialButton(context).apply {
|
||||
text = "查看逐条结果"
|
||||
minimumHeight = context.dp(48)
|
||||
setOnClickListener {
|
||||
MaterialAlertDialogBuilder(context).setTitle("回填明细")
|
||||
.setMessage(state.evidence.joinToString("\n")).setPositiveButton("关闭", null).show()
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
private fun buildFilters(): View {
|
||||
val context = requireContext()
|
||||
val statuses = if (collection) {
|
||||
@@ -632,6 +723,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
private fun registerCurrentPageReceiver() {
|
||||
if (currentPageReceiverRegistered) return
|
||||
val filter = IntentFilter(AgentForegroundService.ACTION_CURRENT_PAGE_RESULT)
|
||||
filter.addAction(AgentForegroundService.ACTION_BACKFILL_STATE)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@@ -724,6 +816,12 @@ class TaskHistoryFragment : Fragment() {
|
||||
append("实际单价:${money(task.actualUnitPriceCent, task.currency)}\n")
|
||||
append("PDD 订单号:${task.pddOrderNo ?: "—"}\n")
|
||||
append("下单时间:${task.orderSubmittedAt?.let(::formatTime) ?: "—"}")
|
||||
val environment = "${AgentSettingsStore(context).serverUrl()}|${runCatching { SecureDeviceStore(context).credentials()?.deviceId }.getOrNull()}"
|
||||
when (TaskHistoryCache(context).backfillTimeSource(task.taskId, environment)) {
|
||||
"page" -> append("(页面读取)")
|
||||
"irreversible_at" -> append("(估算:提交订单时刻)")
|
||||
"existing_unknown" -> append("(已有值,来源未知)")
|
||||
}
|
||||
}
|
||||
resultColumn.addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label("CG-${task.taskId}", 20f, context.getColor(R.color.agent_text), true))
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import cn.ilapage.goauto.agent.network.*
|
||||
import cn.ilapage.goauto.agent.service.*
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
import java.util.TimeZone
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class OrderBackfillTest {
|
||||
private val zone = TimeZone.getTimeZone("Asia/Shanghai")
|
||||
private fun time(raw: String): Long = BackfillDetailReader(zone).apply { accept("下单时间:$raw") }.finish().timeMillis!!
|
||||
private val now = time("2026-09-08 12:00:00")
|
||||
|
||||
@Test fun `expanded detail reads order time not group time across frames`() {
|
||||
val reader = BackfillDetailReader(zone)
|
||||
reader.accept("测试地址_cg7\n订单编号:TEST-000007\n展开")
|
||||
reader.accept("下单时间:2026-09-08 10:50:29\n拼单时间:2026-09-08 11:20:35")
|
||||
val detail = reader.finish()
|
||||
assertEquals("2026-09-08T10:50:29+08:00", detail.item!!.orderSubmittedAt)
|
||||
assertEquals("_cg7", detail.item!!.addressSuffix)
|
||||
assertNull(reader.finish().item)
|
||||
}
|
||||
|
||||
@Test fun `untagged order never becomes retained or uploaded candidate`() {
|
||||
val reader = BackfillDetailReader(zone)
|
||||
reader.accept("个人订单\n订单编号:PERSONAL-1\n下单时间:2026-09-08 10:00:00")
|
||||
assertNull(reader.finish().item)
|
||||
reader.accept("测试地址_cg7")
|
||||
assertNull(reader.finish().item) // previous order number has been cleared
|
||||
val uploaded = mutableListOf<BackfillItem>()
|
||||
scanner(FakeDriver(tagged = false), uploaded).scan()
|
||||
assertTrue(uploaded.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun `ambiguous and noncanonical suffixes are skipped and missing time remains optional`() {
|
||||
for (suffix in listOf("_cg07", "_cg+7", "_cg7", "_cg7abc", "_cg7 _cg8")) {
|
||||
assertNull(BackfillDetailReader(zone).apply { accept("$suffix\n订单号:TEST-000007") }.finish().item)
|
||||
}
|
||||
assertNull(BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n订单号:TEST-000008") }.finish().item)
|
||||
val missing = BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n拼单时间:2026-09-08 10:00:00") }.finish()
|
||||
assertNotNull(missing.item)
|
||||
assertNull(missing.item!!.orderSubmittedAt)
|
||||
}
|
||||
|
||||
@Test fun `rolling hours boundary and sampled descending cutoff`() {
|
||||
val window = OrderBackfillWindow("2", now)
|
||||
assertEquals(now - 48 * 3_600_000L, window.cutoff)
|
||||
assertTrue(window.includes(window.cutoff))
|
||||
assertFalse(window.includes(window.cutoff - 1))
|
||||
repeat(4) { assertFalse(window.observe(window.cutoff + 10 - it)) }
|
||||
assertTrue(window.observe(window.cutoff - 1))
|
||||
assertEquals(Long.MIN_VALUE, OrderBackfillWindow("99999999999999999999999999", now).cutoff)
|
||||
}
|
||||
|
||||
@Test fun `time reversal and missing times disable early stop`() {
|
||||
val window = OrderBackfillWindow("2", now)
|
||||
assertFalse(window.observe(now - 100))
|
||||
assertFalse(window.observe(now))
|
||||
repeat(10) { assertFalse(window.observe(window.cutoff - it - 1)) }
|
||||
assertTrue(window.nonDescending)
|
||||
val unknown = OrderBackfillWindow("2", now)
|
||||
unknown.observe(null)
|
||||
repeat(10) { assertFalse(unknown.observe(unknown.cutoff - it - 1)) }
|
||||
}
|
||||
|
||||
@Test fun `scanner expands and stops after validated prefix passes rolling cutoff`() {
|
||||
val driver = FakeDriver()
|
||||
val items = mutableListOf<BackfillItem>()
|
||||
val result = scanner(driver, items).scan()
|
||||
assertEquals(5, driver.opened)
|
||||
assertEquals(5, driver.expansions)
|
||||
assertTrue(result.contains("已达指定天数"))
|
||||
assertTrue(result.contains("未完整扫描"))
|
||||
assertTrue(items.all { !it.orderSubmittedAt.orEmpty().contains("11:20") })
|
||||
}
|
||||
|
||||
@Test fun `unordered list scans to internal cap and reports incomplete`() {
|
||||
val window = OrderBackfillWindow("2", now)
|
||||
val driver = FakeDriver(unordered = true)
|
||||
val result = OrderBackfillScanner(driver, window, {}, {}, {}).scan()
|
||||
assertTrue(window.nonDescending)
|
||||
assertEquals(200, driver.opened)
|
||||
assertTrue(result.contains("未完整扫描"))
|
||||
assertTrue(result.contains(OrderBackfillWindow.UNORDERED))
|
||||
}
|
||||
|
||||
@Test fun `mutex occupied rejects and simultaneous double tap has one winner and finally releases`() {
|
||||
val mutex = TaskExecutionMutex()
|
||||
val guard = OrderBackfillGuard(mutex)
|
||||
mutex.tryAcquire(7)
|
||||
assertFalse(guard.tryAcquire())
|
||||
mutex.release(7)
|
||||
val start = CountDownLatch(1)
|
||||
val complete = CountDownLatch(2)
|
||||
val won = AtomicInteger()
|
||||
val pool = Executors.newFixedThreadPool(2)
|
||||
repeat(2) { pool.execute { start.await(); if (guard.tryAcquire()) won.incrementAndGet(); complete.countDown() } }
|
||||
start.countDown()
|
||||
complete.await()
|
||||
pool.shutdownNow()
|
||||
assertEquals(1, won.get())
|
||||
assertFalse(mutex.tryAcquire(8))
|
||||
try { guard.cancelled.set(true); throw IllegalStateException("cancelled") } catch (_: IllegalStateException) { } finally { guard.release() }
|
||||
assertNull(mutex.currentTaskId())
|
||||
assertTrue(guard.tryAcquire())
|
||||
assertFalse(guard.cancelled.get())
|
||||
guard.release()
|
||||
}
|
||||
|
||||
@Test fun `permanent business errors never retry regardless of server retryable flag`() {
|
||||
val codes = listOf("PURCHASE_BACKFILL_SUFFIX_INVALID", "PURCHASE_TASK_NOT_FOUND", "PURCHASE_BACKFILL_DEVICE_MISMATCH",
|
||||
"PURCHASE_STATE_CONFLICT", "PURCHASE_INVALID_REQUEST", "PURCHASE_ORDER_TIME_INVALID", "PURCHASE_ORDER_TIME_MISSING",
|
||||
"PURCHASE_BACKFILL_ORDER_CONFLICT", "PURCHASE_BACKFILL_BATCH_CONFLICT", "PURCHASE_BACKFILL_ORDER_ALREADY_USED")
|
||||
codes.forEach { code ->
|
||||
var calls = 0
|
||||
val results = mutableListOf<BackfillResult>()
|
||||
OrderBackfillUpload({ _, _ -> calls++; listOf(failure(code)) }, {}, {}).upload(listOf(item), results::add)
|
||||
assertEquals(1, calls)
|
||||
assertTrue(results.single().display().contains("需人工检查"))
|
||||
}
|
||||
var calls = 0
|
||||
OrderBackfillUpload({ _, _ -> calls++; throw AgentApiException(409, codes.first(), "hidden", true) }, {}, {})
|
||||
.upload(listOf(item)) { assertTrue(it.needsReview) }
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
|
||||
@Test fun `network and internal errors retry bounded with stable transport request id`() {
|
||||
val ids = mutableListOf<String>()
|
||||
val results = mutableListOf<BackfillResult>()
|
||||
OrderBackfillUpload({ id, _ -> ids += id; throw IOException("private body") }, {}, {})
|
||||
.upload(listOf(item), results::add)
|
||||
assertEquals(3, ids.size)
|
||||
assertEquals(1, ids.toSet().size)
|
||||
assertFalse(results.single().needsReview)
|
||||
assertFalse(results.single().display().contains("private"))
|
||||
var calls = 0
|
||||
OrderBackfillUpload({ _, _ -> calls++; listOf(failure("INTERNAL_ERROR")) }, {}, {})
|
||||
.upload(listOf(item)) { assertTrue(it.display().contains("重试耗尽")) }
|
||||
assertEquals(3, calls)
|
||||
}
|
||||
|
||||
@Test fun `partial response only retries transient items and preserves acknowledged success`() {
|
||||
val sizes = mutableListOf<Int>()
|
||||
val results = mutableListOf<BackfillResult>()
|
||||
val second = item.copy(addressSuffix = "_cg8", pddOrderNo = "TEST-000008")
|
||||
OrderBackfillUpload({ _, items ->
|
||||
sizes += items.size
|
||||
if (items.size == 2) listOf(success(), failure("INTERNAL_ERROR").copy(index = 1))
|
||||
else listOf(failure("PURCHASE_STATE_CONFLICT"))
|
||||
}, {}, {}).upload(listOf(item, second), results::add)
|
||||
assertEquals(listOf(2, 1), sizes)
|
||||
assertTrue(results.first().success)
|
||||
assertEquals(1, results.last().index)
|
||||
}
|
||||
|
||||
@Test fun `cancellation prevents upload and invalid response cannot confirm cache`() {
|
||||
var calls = 0
|
||||
try {
|
||||
OrderBackfillUpload({ _, _ -> calls++; listOf(success()) }, { error("stopped") }, {}).upload(listOf(item)) { fail() }
|
||||
fail()
|
||||
} catch (_: IllegalStateException) { }
|
||||
assertEquals(0, calls)
|
||||
try {
|
||||
OrderBackfillUpload({ _, _ -> listOf(success().copy(taskId = 99)) }, {}, {}).upload(listOf(item)) { fail() }
|
||||
fail()
|
||||
} catch (_: IllegalStateException) { }
|
||||
}
|
||||
|
||||
@Test fun `payload contains only suffix order and optional RFC3339 time`() {
|
||||
val payload = backfillPayload("test", listOf(item))
|
||||
assertEquals(setOf("requestId", "items"), payload.keySet())
|
||||
assertEquals(setOf("addressSuffix", "pddOrderNo"), payload.getJSONArray("items").getJSONObject(0).keySet())
|
||||
val parsed = parseBackfillResults(JSONObject("""{"items":[{"index":0,"taskId":7,"result":"backfilled","code":"BACKFILLED","status":"order_created","statusVersion":3,"pddOrderNo":"TEST-000007","orderSubmittedAt":"2026-09-08T10:00:00+08:00","timeSource":"irreversible_at","retryable":false}]}"""))
|
||||
assertTrue(parsed.single().display().contains("估算"))
|
||||
assertTrue(parsed.single().copy(timeSource = "page").display().contains("页面下单时间"))
|
||||
}
|
||||
|
||||
@Test fun `cache updates confirmed server facts only and clears old errors`() {
|
||||
val task = JSONObject().put("taskId", 7).put("status", "order_result_unknown").put("errorCode", "OLD").put("errorMessage", "old")
|
||||
cn.ilapage.goauto.agent.persistence.applyBackfillFacts(task, failure("PURCHASE_BACKFILL_ORDER_CONFLICT"))
|
||||
assertEquals("order_result_unknown", task.getString("status"))
|
||||
assertEquals("OLD", task.getString("errorCode"))
|
||||
cn.ilapage.goauto.agent.persistence.applyBackfillFacts(task, success())
|
||||
assertEquals("order_created", task.getString("status"))
|
||||
assertEquals(item.pddOrderNo, task.getString("pddOrderNo"))
|
||||
assertTrue(task.isNull("errorCode"))
|
||||
assertTrue(task.isNull("errorMessage"))
|
||||
assertFalse(task.getBoolean("retryable"))
|
||||
}
|
||||
|
||||
@Test fun `expansion requires order information and product snapshot row`() {
|
||||
val expand = node("0/1", "展开")
|
||||
assertNull(BackfillPagePolicy.expansion(page(node("0/0", "订单编号:TEST-000007"), expand)))
|
||||
assertEquals(expand, BackfillPagePolicy.expansion(page(node("0/0", "订单编号:TEST-000007"), expand, node("0/2", "商品快照"))))
|
||||
}
|
||||
|
||||
@Test fun `dangerous nodes ancestors and overlapping actions never become click targets`() {
|
||||
BackfillPagePolicy.forbidden.forEach { label ->
|
||||
val dangerous = node("0/0", label)
|
||||
assertFalse(BackfillPagePolicy.safe(page(dangerous), dangerous))
|
||||
val parent = node("0", "查看详情")
|
||||
assertFalse(BackfillPagePolicy.safe(page(parent, dangerous), parent))
|
||||
val adjacent = node("0/1", "查看详情")
|
||||
assertFalse(BackfillPagePolicy.safe(page(adjacent, dangerous), adjacent))
|
||||
}
|
||||
}
|
||||
|
||||
private val item = BackfillItem("_cg7", "TEST-000007", null)
|
||||
private fun failure(code: String) = BackfillResult(0, 7, "failed", code, "", 0, null, null, "")
|
||||
private fun success() = BackfillResult(0, 7, "backfilled", "BACKFILLED", "order_created", 3, item.pddOrderNo, null, "page")
|
||||
private fun scanner(driver: FakeDriver, items: MutableList<BackfillItem>) = OrderBackfillScanner(driver, OrderBackfillWindow("2", now), {}, items::add, {})
|
||||
private fun node(path: String, label: String, selected: Boolean = false) = SnapshotNode(path, path.substringBeforeLast('/'), label, null, null, "View",
|
||||
NodeBounds(0, 0, 200, 60), true, false, selected, false, true, true)
|
||||
private fun page(vararg nodes: SnapshotNode) = UiSnapshot(BackfillPagePolicy.PDD, "com.xunmeng.pinduoduo.activity.NewPageActivity", nodes.toList())
|
||||
|
||||
private inner class FakeDriver(val tagged: Boolean = true, val unordered: Boolean = false) : BackfillDriver {
|
||||
var opened = 0
|
||||
var expansions = 0
|
||||
var index = 0
|
||||
var inDetail = false
|
||||
var expanded = false
|
||||
override fun openOrders() = Unit
|
||||
override fun pause() = Unit
|
||||
override fun capture(): UiSnapshot {
|
||||
if (!inDetail) return page(node("0/0", "我的订单"), node("0/1", "全部", true), node("0/2", "查看详情"), node("0/2/0", "合成卡片 $index"))
|
||||
val date = if (unordered && index == 1) "2026-09-08 11:00:00" else if (index < 4) "2026-09-08 10:00:00" else "2026-09-05 10:00:00"
|
||||
return page(node("0/0", "订单详情"), node("0/1", "订单编号:TEST-${100000 + index}"),
|
||||
node("0/2", if (tagged) "合成地址_cg${index + 1}" else "无后缀合成地址"),
|
||||
node("0/4", "商品快照"),
|
||||
node("0/3", if (expanded) "下单时间:$date\n拼单时间:2026-09-08 11:20:00" else "展开"))
|
||||
}
|
||||
override fun click(node: SnapshotNode): Boolean {
|
||||
if (node.label == "展开") { expanded = true; expansions++ } else { inDetail = true; opened++ }
|
||||
return true
|
||||
}
|
||||
override fun scroll() = false
|
||||
override fun back(): Boolean { inDetail = false; expanded = false; index++; return true }
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: f465790ee95fef0f1a501626da50cc66d740f0fc
|
||||
synchronized_at: 2026-09-08T01:38:06Z
|
||||
wiki_revision: d547c17924ac53422232ad9d6c55a34c8cd8d63c
|
||||
synchronized_at: 2026-09-08T07:16:20Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -68,6 +68,8 @@ Android Portal/Agent
|
||||
| `pdd_account` | 可选的账号调度引用,只保存名称和状态,不保存凭据 |
|
||||
| `purchase_task` | 商品外键和不可变快照、执行模式、状态/租约 guard、价格边界、订单、人工支付复核、物流与回填事实 |
|
||||
| `purchase_task_attempt` | `task_id + attempt_id` 幂等执行记录、阶段、规则哈希、固化规格决策和结构化错误 |
|
||||
|
||||
> #241 新增:`server/app/goauto/purchase/order_backfill.go` 与 `order_backfill_handler.go` 提供 `POST /api/agent/v1/purchase-tasks/order-backfill`(Device Token 鉴权,逐条事务、逐条结果);`server/app/goauto/models/purchase_order_guard.go` 在 `PurchaseTask.BeforeSave` 上全局强制订单号唯一,以 `purchase_rule_setting` 单例行串行化订单号分配,避免新增唯一索引迁移;`purchasecontract.ParseAddressSuffix` 为 `AddressSuffix` 的反解,通过回比而非负向前瞻实现(Go RE2 不支持前瞻)。
|
||||
| `ai_matching_setting` | 唯一单例的启用状态、OpenAI-compatible Base URL、模型、超时、内部部署明文 API Key 和更新人;仅管理员设置接口可以读取该字段 |
|
||||
|
||||
`collection_task` 的状态仅为 `pending`、`running`、`completed`、`completed_partial`、`failed`。设备身份和心跳表属于 Agent 领取任务的必要基础,不承载 PDD 业务数据。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 7d7f515652a2b24880e55cfd95e94d3c25cbbc4d
|
||||
synchronized_at: 2026-09-08T02:22:59Z
|
||||
wiki_revision: 1eb380d07183157a8430c7ced868479e72fbdfe3
|
||||
synchronized_at: 2026-09-08T07:49:50Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -58,6 +58,13 @@ synchronized_at: 2026-09-08T02:22:59Z
|
||||
- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、规格待匹配、可创建采购、已创建任务、采购成功、待人工核对。
|
||||
- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 规格待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。
|
||||
- “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。
|
||||
- 一个 PDD 订单号只能属于一个采购任务,该唯一性在采购任务保存路径上全局强制(#241)。人工处理结果未知、取消及 lifecycle 保存路径撞号时返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`,提示订单号已属于哪个任务,由采购员人工核对,不静默覆盖原值。
|
||||
- 不可逆边界之后的 `order_created` 结果回传是上述规则的例外:此时 PDD 真单已创建,发现订单号已属于其他任务时不回滚、不判失败,而是把任务降级为 `order_result_unknown`,冲突订单号以「读到订单号 X,但该号已属于任务 CG-yy」保存在任务与 attempt 的 `error_message`,`pdd_order_no` 留空以维持唯一性,保留下单时间与不可逆时间,进入既有人工处理结果未知通道。首要目标是保住「真单已存在」这一事实,不制造无记录的真实订单。
|
||||
- Agent 可按收货地址后缀 `_cg<任务号>` 批量回填订单号与下单时间(#241)。
|
||||
- Agent 侧回填为**人工触发的只读扫描**(#242):入口在采购记录页搜索按钮右侧,输入天数(默认 2)后确认启动。天数口径为滚动 N×24 小时,基准是页面读到的下单时间。采集、采购、回填三者互斥,复用既有任务互斥锁并加原子防重入,忙碌时拒绝启动而不排队、不抢占。
|
||||
- 回填扫描不假设订单列表有序:只有连续观察到至少 5 单且下单时间严格递减、期间无缺失时间时,才允许「超过指定时间即停止」;一旦出现时间回升或缺失,改为扫至内部上限并明确标记「未完整扫描」,不得把不完整结果显示为已扫完。内部上限用于限制设备占用,不作为用户可配置的门禁。
|
||||
- 回填扫描全程只读:确认收货、申请退款、催发货、去支付、立即支付、提交订单、付款、退款、取消订单、再次购买、删除订单永久排除为点击目标;点击目标必须自身可点、子树不含上述词,且不与任何含上述词的可见控件几何重叠;不得按固定坐标盲点。遇登录、验证码或风控立即停止并报告。
|
||||
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号与可选下单时间。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
|
||||
- 已失败、已取消或演练完成的旧采购任务不单独占用主阶段;当前数据仍满足条件时恢复显示“可创建采购”,旧任务继续在采购管理留痕和按既有规则处理。
|
||||
- 未选择处理阶段时,商品列表仍先返回,当前页阶段和采购准备继续异步批量读取且不调用 AI Provider;选择阶段筛选时,服务端必须先对完整查询结果派生并筛选阶段,再计算总数和分页,不能只过滤当前页。
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: 217239a85b50c880e004ecc19a559fb5e03555e1
|
||||
synchronized_at: 2026-09-08T01:38:42Z
|
||||
wiki_revision: 1f5ee1b29c66773fa571d862241b63f02dae283b
|
||||
synchronized_at: 2026-09-08T07:09:23Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -556,7 +556,9 @@ Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attem
|
||||
创建订单前先在本地事务保存 `order_submit_started`、不可逆时间、稳定的 `orderSubmitRequestId` 和脱敏最终确认快照,再调用服务端同名接口;只有两侧标记完成并重新校验商品、规格、数量、单价、地址后缀、PDD 包/Activity 与唯一创建订单按钮后,才点击一次。重启时重放同一标记请求并只读核单;无法取得唯一未付款订单号和 PDD 下单时间时提交 `order_result_unknown`。支付文字仅用于识别未付款/离开支付页,永不点击。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。
|
||||
|
||||
创建订单后若出现 Android 多微信应用选择器,只读核单器必须同时确认前台包为 `android` / `com.android.intentresolver`、Activity 为白名单 `ChooserActivity` / `ResolverActivity`、页面出现已知系统选择器标题且至少一个候选以“微信”开头,才允许执行一次系统返回;不得点击任何微信候选。若前台已经是精确微信包 `com.tencent.mm`,只读核单器不得点击、输入、登录、支付或强制停止微信,只允许执行一次无参数 PDD 启动 Intent 并等待既有 PDD 任务栈回到前台;`startActivity()` 成功只表示恢复请求已发起。请求后在固定最多 15 次、每次 200ms 的宽限期内允许微信或空窗口短暂残留,不执行点击、返回、输入或滑动;观察到 PDD 后结束宽限,宽限超时、已经观察到 PDD 后再次进入微信或出现稳定未知应用时返回未知结果。随后若前台为 PDD `com.xunmeng.pinduoduo.app_pay.core.PayActivity` 或当前页面出现支付动作文字,最多再返回一次。返回后只读解析唯一订单号和下单时间;选择器或支付页重复出现、白名单不成立、恢复动作重复、无法到达订单详情、结果不唯一或超时均返回未知结果,禁止再次点击创建订单、取消订单或支付。
|
||||
`order_result_unknown` 保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单并按错误码写入固定提示,不信任或保存页面原文;阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
|
||||
Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单并按错误码写入固定提示,不信任或保存页面原文;阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
|
||||
|
||||
#241 追加:全局订单号唯一性校验在人工处理结果未知、取消及 lifecycle 保存路径返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`(HTTP 409、`retryable=false`),提示“订单号已属于任务 CG-任务ID”;批量回填继续使用原有 `PURCHASE_BACKFILL_ORDER_ALREADY_USED`。不可逆边界后的 `order_created` 结果回传为例外:发现该号已属于其他任务时成功受理结果,将 `order_submit_started` 降级为 `order_result_unknown`,不回滚或自动重派。冲突订单号不写入 `pdd_order_no`,而以“读到订单号 X,但该号已属于任务 CG-yy”保存到任务和 attempt 的 `error_message`,两者 `error_code` 均为 `PURCHASE_ORDER_NUMBER_ALREADY_USED`;保留下单时间、不可逆时间及实际单价。attempt 以 failed 结束并保留原始 `order_created` 结果类型、请求 ID 和摘要,重复提交按原幂等协议返回;任务释放租约和运行槽,进入既有人工处理结果未知通道,权限不变。
|
||||
|
||||
| 错误码 | 普通提示 |
|
||||
|---|---|
|
||||
@@ -985,3 +987,76 @@ Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口
|
||||
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
|
||||
|
||||
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
## Agent 采购订单批量回填(#241)
|
||||
|
||||
本节为 #241 服务端实现契约,2026-09-08 按用户授权直接更新本地镜像;线上 Wiki 与其他长期文档由审核阶段同步。本节不表示已经部署或完成真机验收。
|
||||
|
||||
`POST /api/agent/v1/purchase-tasks/order-backfill`
|
||||
|
||||
使用 `Authorization: Bearer <Device Token>`,沿用 `RequireAgentHTTPS`、`GOAUTO_ALLOW_INSECURE_AGENT_HTTP` 与既有可信转发协议策略。无需 Admin JWT、claim、start 或 attempt。设备号只从认证读取,请求不得指定 deviceId、地址全文、收件人、手机号或原始控件树;未知 JSON 字段拒绝。此接口只记录已观察到的订单事实,不执行设备动作、创建订单或付款。
|
||||
|
||||
请求示例(页面时间先按 Asia/Shanghai 理解,再以带时区 RFC3339/RFC3339Nano 发送):
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "5826cdda-dcd6-442e-90c3-9b75ba6fb8d8",
|
||||
"items": [
|
||||
{"addressSuffix": "_cg7", "pddOrderNo": "EXAMPLE-ORDER-7", "orderSubmittedAt": "2026-09-08T20:30:00+08:00"},
|
||||
{"addressSuffix": "_cg72", "pddOrderNo": "EXAMPLE-ORDER-72"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- requestId 必须为 UUID;items 为 1~50 条,保持输入顺序;请求体上限沿用 1 MiB。
|
||||
- addressSuffix 只接受 `AddressSuffix(id)` 生成的完整字符串。任务号为非零 uint64,拒绝前导零、正负号、空格、尾随文本、多个后缀与溢出;`_cg7` 与 `_cg72` 分别定位任务 7 和 72。
|
||||
- pddOrderNo 必填,最多 100 个 Unicode 字符,不接受首尾空白、换行或制表符,不自动裁剪后覆盖旧值。
|
||||
- orderSubmittedAt 缺失或 null 时回落任务 irreversible_at;空字符串、无时区文本及非法时间是条目错误,不触发回落。存储统一 UTC。页面值与 irreversible_at 都缺失时该条失败。
|
||||
|
||||
有效批次返回 HTTP 200,包括全部条目失败的批次;每条独立事务,失败不撤销其他条目已提交的数据。响应包裹为 `data`,并设 `Cache-Control: no-store`:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"requestId": "5826cdda-dcd6-442e-90c3-9b75ba6fb8d8",
|
||||
"items": [
|
||||
{"index": 0, "taskId": 7, "result": "backfilled", "code": "BACKFILLED", "status": "order_created", "statusVersion": 5, "pddOrderNo": "EXAMPLE-ORDER-7", "orderSubmittedAt": "2026-09-08T12:30:00Z", "timeSource": "page", "retryable": false},
|
||||
{"index": 1, "taskId": 72, "result": "backfilled", "code": "BACKFILLED", "status": "order_created", "statusVersion": 4, "pddOrderNo": "EXAMPLE-ORDER-72", "orderSubmittedAt": "2026-09-08T12:31:00Z", "timeSource": "irreversible_at", "retryable": false}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
index 从 0 开始;后缀无法解析时不返回 taskId。result 为 `backfilled`、`already_backfilled`、`conflict` 或 `failed`。已认证设备所属任务可返回提交后的状态、版本、已保存订单号及时间;拒绝条目尽可能返回当前已提交事实。跨设备任务和不存在任务不返回这些业务字段,事务回滚后的内存值绝不作为最终事实返回。
|
||||
|
||||
timeSource 说明已保存时间的来源:`page` 为页面值,`irreversible_at` 为估算回落,`existing_unknown` 为原先已创建的历史订单且没有可证明的来源。没有已保存时间时省略 timeSource。客户端必须保留估算标记,不得把回落值或 unknown 宣称为页面真实时间。重复回填不会用新页面时间自动校正旧时间。
|
||||
|
||||
| 条目 code | result | 含义 |
|
||||
|---|---|---|
|
||||
| `BACKFILLED` | backfilled | 本条完成回填 |
|
||||
| `ALREADY_BACKFILLED` | already_backfilled | 正式任务已为 order_created 且订单号相同,无写入 |
|
||||
| `PURCHASE_BACKFILL_SUFFIX_INVALID` | failed | 非法、非规范、零、溢出或歧义后缀 |
|
||||
| `PURCHASE_TASK_NOT_FOUND` | failed | 任务不存在 |
|
||||
| `PURCHASE_BACKFILL_DEVICE_MISMATCH` | failed | 未绑定设备或不属于认证设备 |
|
||||
| `PURCHASE_STATE_CONFLICT` | failed | 非正式采购,或状态不允许回填 |
|
||||
| `PURCHASE_INVALID_REQUEST` | failed | 订单号非法 |
|
||||
| `PURCHASE_ORDER_TIME_INVALID` | failed | 提供的页面时间无效 |
|
||||
| `PURCHASE_ORDER_TIME_MISSING` | failed | 页面时间与 irreversible_at 均无有效值 |
|
||||
| `PURCHASE_BACKFILL_ORDER_CONFLICT` | conflict | 任务已有不同订单号 |
|
||||
| `PURCHASE_BACKFILL_BATCH_CONFLICT` | conflict | 同批同任务出现多个不同订单号,该任务所有条目均拒绝 |
|
||||
| `PURCHASE_BACKFILL_ORDER_ALREADY_USED` | conflict | 同一订单号已对应其他任务 |
|
||||
| `INTERNAL_ERROR` | failed | 数据库失败、死锁等,retryable=true,可安全重放 |
|
||||
|
||||
批级 JSON/UUID/数量错误为 HTTP 422 `PURCHASE_INVALID_REQUEST`;鉴权、停用设备和 HTTPS 限制复用既有错误(401 `DEVICE_TOKEN_INVALID`、403 `DEVICE_DISABLED`、426 `HTTPS_REQUIRED`)。批级失败使用既有 `{code,message,retryable}` 包裹,未开始条目写入。
|
||||
|
||||
### 状态、幂等与并发
|
||||
|
||||
新服务在事务中锁定任务并检查来源状态,仅允许当前设备的 `live + order_result_unknown` 首次写入;`live + order_created` 只在订单号相同时返回已回填。其他状态(包括 running、failed、cancelled 与演练)均拒绝。复用 SetStatus 同步占用字段,同一事务递增 statusVersion、设置 statusChangedAt、清空主任务当前错误及租约;原始 attempt、规则快照、支付与物流、SYB 回写字段不变。
|
||||
|
||||
requestId 沿用 UUID 约定,不增加批次表或全局幂等缓存。既有 unknown_resolve_request_id 槽存储 `backfill:<page|irreversible_at>:<由 requestId 和后缀派生的 UUID>`(最多 61 字符),用于任务级关联和保留本功能时间来源。相同任务和订单号即使更换 requestId 也无写入;同 requestId 改内容仍重新执行设备、状态和订单冲突检查,不凭 requestId 直接放行。批内不同任务提交同一订单号时,先成功提交者占用,其余条目返回订单已被使用;已存在的历史重复订单号不自动修复。
|
||||
|
||||
订单号尚无唯一索引,本实现不迁移数据库。共享模型保存钩子复用 `purchase_rule_setting.id=1` 行作短事务互斥锁,再以锁定读检查订单号归属,覆盖回填、原人工解除和旧结果提交路径;单例缺失时拒绝写入,数据库死锁时回滚失败事务。原 ResolveUnknown 与 Admin 鉴权代码保持不变。禁止通过跳过模型钩子的直接 SQL 写入宣称具备此保证。
|
||||
|
||||
新回填路径禁用包含绑定参数的 SQL 日志,不记录请求正文、订单号、地址或原始树;任务号和认证设备号沿既有任务关联不可变 ruleSnapshot。此接口未新增日志载荷或任务/attempt。
|
||||
|
||||
本地测试覆盖事务回滚、并发服务调用和旧写入路径,使用 SQLite;MySQL 8.4 多连接/多进程的实际行锁、生产数据和真机端到端回填尚待环境验收,不以单元测试替代。
|
||||
|
||||
@@ -202,7 +202,15 @@ func (task *PurchaseTask) BeforeCreate(_ *gorm.DB) error {
|
||||
return task.syncPurchaseGuardSlots()
|
||||
}
|
||||
|
||||
func (task *PurchaseTask) BeforeSave(_ *gorm.DB) error { return task.syncPurchaseGuardSlots() }
|
||||
func (task *PurchaseTask) BeforeSave(tx *gorm.DB) error {
|
||||
if err := task.syncPurchaseGuardSlots(); err != nil {
|
||||
return err
|
||||
}
|
||||
if task.PDDOrderNo != nil && *task.PDDOrderNo != "" {
|
||||
return CheckPurchaseOrderNumber(tx, task.ID, *task.PDDOrderNo)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (task *PurchaseTask) SetStatus(status string) error {
|
||||
task.Status = status
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var ErrPurchaseOrderNumberUsed = errors.New("purchase order number belongs to another task")
|
||||
|
||||
type PurchaseOrderNumberUsedError struct {
|
||||
TaskID uint64
|
||||
}
|
||||
|
||||
func (e *PurchaseOrderNumberUsedError) Error() string {
|
||||
return fmt.Sprintf("订单号已属于任务 CG-%d", e.TaskID)
|
||||
}
|
||||
|
||||
func (e *PurchaseOrderNumberUsedError) Unwrap() error { return ErrPurchaseOrderNumberUsed }
|
||||
|
||||
// CheckPurchaseOrderNumber must run inside the caller's write transaction.
|
||||
// The existing singleton setting row serializes order assignments across
|
||||
// processes, including an absent order number, without relying on gap locks or
|
||||
// a new schema constraint. Locking reads see the latest committed assignment.
|
||||
// A missing singleton fails closed. Deadlocks roll back the losing transaction.
|
||||
func CheckPurchaseOrderNumber(tx *gorm.DB, taskID uint64, orderNo string) error {
|
||||
var setting PurchaseRuleSetting
|
||||
if err := tx.Session(&gorm.Session{NewDB: true}).Clauses(clause.Locking{Strength: "UPDATE"}).First(&setting, 1).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var others []PurchaseTask
|
||||
if err := tx.Session(&gorm.Session{NewDB: true}).Select("id").Clauses(clause.Locking{Strength: "UPDATE"}).Where("pdd_order_no = ? AND id <> ?", orderNo, taskID).Find(&others).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(others) != 0 {
|
||||
return &PurchaseOrderNumberUsedError{TaskID: others[0].ID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -542,7 +542,7 @@ func writeError(c *gin.Context, err error) {
|
||||
status = http.StatusForbidden
|
||||
case CodeTaskNotFound:
|
||||
status = http.StatusNotFound
|
||||
case CodeStateConflict, CodeCapabilityMismatch, CodeDeviceBusy, CodeTaskClaimed, CodeLeaseExpired, CodeMappingRequired, CodeResultConflict, CodeRePurchaseRequired:
|
||||
case CodeStateConflict, CodeCapabilityMismatch, CodeDeviceBusy, CodeTaskClaimed, CodeLeaseExpired, CodeMappingRequired, CodeResultConflict, CodeRePurchaseRequired, CodeOrderNumberUsed:
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"code": code, "message": msg, "retryable": retryable})
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -378,6 +379,22 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
|
||||
t.PDDOrderNo = &req.PDDOrderNo
|
||||
t.OrderSubmittedAt = req.OrderSubmittedAt
|
||||
t.ActualUnitPriceCent = req.ActualUnitPriceCent
|
||||
// Keep the assignment lock until commit. A conflicting observation
|
||||
// after the irreversible boundary must reach manual resolution,
|
||||
// not roll back the result or claim another task's order number.
|
||||
if e := models.CheckPurchaseOrderNumber(tx, t.ID, req.PDDOrderNo); e != nil {
|
||||
var conflict *models.PurchaseOrderNumberUsedError
|
||||
if !errors.As(e, &conflict) {
|
||||
return TaskPayload{}, conflictOrInternal(e)
|
||||
}
|
||||
next = models.PurchaseTaskStatusOrderResultUnknown
|
||||
t.PDDOrderNo = nil
|
||||
failureCode := CodeOrderNumberUsed
|
||||
message := fmt.Sprintf("读到订单号 %s,但该号已属于任务 %s", req.PDDOrderNo, taskNumber(conflict.TaskID))
|
||||
t.ErrorCode, t.ErrorMessage = &failureCode, &message
|
||||
a.Status = models.PurchaseAttemptStatusFailed
|
||||
a.ErrorCode, a.ErrorMessage = &failureCode, &message
|
||||
}
|
||||
case "order_result_unknown":
|
||||
if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusOrderSubmitStarted {
|
||||
return TaskPayload{}, fail(CodeStateConflict, "当前任务不能标记订单结果未知")
|
||||
@@ -525,7 +542,10 @@ func (s *Service) applySpecDecision(ctx context.Context, taskID uint64, req Spec
|
||||
}
|
||||
t.StatusVersion++
|
||||
t.StatusChangedAt = s.Now()
|
||||
return tx.Save(&t).Error
|
||||
if e := tx.Save(&t).Error; e != nil {
|
||||
return conflictOrInternal(e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return t, replayed, err
|
||||
}
|
||||
@@ -728,6 +748,10 @@ func purchaseNotFound(err error) error {
|
||||
return internal(err)
|
||||
}
|
||||
func conflictOrInternal(err error) error {
|
||||
var conflict *models.PurchaseOrderNumberUsedError
|
||||
if errors.As(err, &conflict) {
|
||||
return fail(CodeOrderNumberUsed, conflict.Error())
|
||||
}
|
||||
if isDuplicate(err) {
|
||||
return fail(CodeDeviceBusy, "设备或拼多多账号已有运行任务")
|
||||
}
|
||||
|
||||
@@ -80,7 +80,10 @@ func (s *Service) SelectWriteback(ctx context.Context, id uint64, req ManualRequ
|
||||
}
|
||||
out.WritebackStatus = models.PurchaseWritebackStatusPending
|
||||
out.WritebackSelectRequestID = &req.RequestID
|
||||
return tx.Save(&out).Error
|
||||
if e := tx.Save(&out).Error; e != nil {
|
||||
return conflictOrInternal(e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, replayed, err
|
||||
}
|
||||
@@ -166,7 +169,10 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl
|
||||
return internal(e)
|
||||
}
|
||||
}
|
||||
return tx.Save(&out).Error
|
||||
if e := tx.Save(&out).Error; e != nil {
|
||||
return conflictOrInternal(e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, replayed, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxOrderBackfillItems = 50
|
||||
CodeBackfillSuffix = "PURCHASE_BACKFILL_SUFFIX_INVALID"
|
||||
CodeBackfillDevice = "PURCHASE_BACKFILL_DEVICE_MISMATCH"
|
||||
CodeBackfillOrderConflict = "PURCHASE_BACKFILL_ORDER_CONFLICT"
|
||||
CodeBackfillBatchConflict = "PURCHASE_BACKFILL_BATCH_CONFLICT"
|
||||
CodeBackfillOrderUsed = "PURCHASE_BACKFILL_ORDER_ALREADY_USED"
|
||||
)
|
||||
|
||||
type OrderBackfillRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Items []OrderBackfillItem `json:"items"`
|
||||
}
|
||||
|
||||
type OrderBackfillItem struct {
|
||||
AddressSuffix string `json:"addressSuffix"`
|
||||
PDDOrderNo string `json:"pddOrderNo"`
|
||||
// A string keeps an invalid page timestamp local to this item.
|
||||
OrderSubmittedAt *string `json:"orderSubmittedAt,omitempty"`
|
||||
}
|
||||
|
||||
type OrderBackfillResult struct {
|
||||
Index int `json:"index"`
|
||||
TaskID uint64 `json:"taskId,omitempty"`
|
||||
Result string `json:"result"`
|
||||
Code string `json:"code"`
|
||||
Status string `json:"status,omitempty"`
|
||||
StatusVersion uint64 `json:"statusVersion,omitempty"`
|
||||
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
|
||||
TimeSource string `json:"timeSource,omitempty"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
|
||||
type OrderBackfillResponse struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Items []OrderBackfillResult `json:"items"`
|
||||
}
|
||||
|
||||
func (s *Service) BackfillOrders(ctx context.Context, req OrderBackfillRequest, token string) (OrderBackfillResponse, error) {
|
||||
out := OrderBackfillResponse{RequestID: req.RequestID}
|
||||
d, err := device.NewService(s.DB).Authenticate(ctx, token)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if _, err := uuid.Parse(req.RequestID); err != nil || len(req.Items) == 0 || len(req.Items) > MaxOrderBackfillItems {
|
||||
return out, fail(CodeInvalidRequest, "requestId 必须为 UUID,items 必须包含 1 到 50 条")
|
||||
}
|
||||
ids := make([]uint64, len(req.Items))
|
||||
orders := make(map[uint64]string)
|
||||
conflicts := make(map[uint64]bool)
|
||||
for i, item := range req.Items {
|
||||
id, err := purchasecontract.ParseAddressSuffix(item.AddressSuffix)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ids[i] = id
|
||||
if previous, ok := orders[id]; ok && previous != item.PDDOrderNo {
|
||||
conflicts[id] = true
|
||||
}
|
||||
orders[id] = item.PDDOrderNo
|
||||
}
|
||||
out.Items = make([]OrderBackfillResult, len(req.Items))
|
||||
for i, item := range req.Items {
|
||||
r := OrderBackfillResult{Index: i, TaskID: ids[i], Result: "failed"}
|
||||
if ids[i] == 0 {
|
||||
r.Code = CodeBackfillSuffix
|
||||
} else {
|
||||
r = s.backfillOrder(ctx, d.ID, ids[i], req.RequestID, item, conflicts[ids[i]])
|
||||
r.Index = i
|
||||
}
|
||||
out.Items[i] = r
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) backfillOrder(ctx context.Context, deviceID, taskID uint64, requestID string, item OrderBackfillItem, batchConflict bool) OrderBackfillResult {
|
||||
r := OrderBackfillResult{TaskID: taskID, Result: "failed"}
|
||||
var task models.PurchaseTask
|
||||
// SQL errors must not print bound order numbers or the task's address snapshot.
|
||||
db := s.DB.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).WithContext(ctx)
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, taskID).Error; err != nil {
|
||||
return purchaseNotFound(err)
|
||||
}
|
||||
if task.DeviceID == nil || *task.DeviceID != deviceID {
|
||||
return fail(CodeBackfillDevice, "任务不属于当前设备")
|
||||
}
|
||||
if batchConflict {
|
||||
return fail(CodeBackfillBatchConflict, "同批任务有不同订单号")
|
||||
}
|
||||
if task.ExecutionMode != models.PurchaseExecutionModeLive || (task.Status != models.PurchaseTaskStatusOrderResultUnknown && task.Status != models.PurchaseTaskStatusOrderCreated) {
|
||||
return fail(CodeStateConflict, "当前任务不允许回填")
|
||||
}
|
||||
if item.PDDOrderNo == "" || strings.TrimSpace(item.PDDOrderNo) != item.PDDOrderNo || utf8.RuneCountInString(item.PDDOrderNo) > 100 || strings.ContainsAny(item.PDDOrderNo, "\r\n\t") {
|
||||
return fail(CodeInvalidRequest, "订单号无效")
|
||||
}
|
||||
if task.PDDOrderNo != nil && *task.PDDOrderNo != "" && *task.PDDOrderNo != item.PDDOrderNo {
|
||||
return fail(CodeBackfillOrderConflict, "已有不同订单号")
|
||||
}
|
||||
// The shared model guard also protects manual resolution and late results.
|
||||
if err := models.CheckPurchaseOrderNumber(tx, taskID, item.PDDOrderNo); err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Status == models.PurchaseTaskStatusOrderCreated {
|
||||
if task.PDDOrderNo == nil || *task.PDDOrderNo != item.PDDOrderNo {
|
||||
return fail(CodeStateConflict, "已创建订单缺少匹配订单号")
|
||||
}
|
||||
r.Result, r.Code = "already_backfilled", "ALREADY_BACKFILLED"
|
||||
return nil
|
||||
}
|
||||
var submitted time.Time
|
||||
source := "page"
|
||||
if item.OrderSubmittedAt != nil {
|
||||
var err error
|
||||
submitted, err = time.Parse(time.RFC3339Nano, *item.OrderSubmittedAt)
|
||||
if err != nil || submitted.IsZero() || submitted.Year() < 1000 || submitted.Year() > 9999 {
|
||||
return fail(CodeOrderTimeInvalid, "下单时间必须为 RFC3339")
|
||||
}
|
||||
} else {
|
||||
if task.IrreversibleAt == nil || task.IrreversibleAt.IsZero() {
|
||||
return fail(CodeOrderTimeMissing, "下单时间和不可逆时间均缺失")
|
||||
}
|
||||
submitted, source = *task.IrreversibleAt, "irreversible_at"
|
||||
}
|
||||
submitted = submitted.UTC()
|
||||
task.PDDOrderNo, task.OrderSubmittedAt = &item.PDDOrderNo, &submitted
|
||||
if err := task.SetStatus(models.PurchaseTaskStatusOrderCreated); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
task.StatusVersion++
|
||||
task.StatusChangedAt = s.Now()
|
||||
task.ErrorCode, task.ErrorMessage = nil, nil
|
||||
task.LeaseExpiresAt = nil
|
||||
// Reuse the existing resolution request slot. Scope a batch UUID to a
|
||||
// task, and retain provenance without a schema change or replay cache.
|
||||
marker := "backfill:" + source + ":" + uuid.NewSHA1(uuid.NameSpaceOID, []byte(requestID+":"+item.AddressSuffix)).String()
|
||||
task.UnknownResolveRequestID = &marker
|
||||
if err := tx.Save(&task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.Result, r.Code = "backfilled", "BACKFILLED"
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
r.Result, r.Code = "failed", CodeInternal
|
||||
r.Retryable = true
|
||||
var se *ServiceError
|
||||
if errors.As(err, &se) {
|
||||
r.Code, r.Retryable = se.Code, se.Retryable
|
||||
}
|
||||
if errors.Is(err, models.ErrPurchaseOrderNumberUsed) {
|
||||
r.Code, r.Retryable = CodeBackfillOrderUsed, false
|
||||
}
|
||||
if r.Code == CodeBackfillBatchConflict || r.Code == CodeBackfillOrderConflict || r.Code == CodeBackfillOrderUsed {
|
||||
r.Result = "conflict"
|
||||
}
|
||||
}
|
||||
// Return only this device's committed facts, including on a rejected item.
|
||||
// Never return in-memory changes from a rolled back transaction.
|
||||
saved := task
|
||||
readable := err == nil
|
||||
if !readable {
|
||||
saved = models.PurchaseTask{}
|
||||
readable = db.Where("id = ? AND device_id = ?", taskID, deviceID).First(&saved).Error == nil
|
||||
}
|
||||
if readable {
|
||||
r.Status, r.StatusVersion = saved.Status, saved.StatusVersion
|
||||
r.PDDOrderNo, r.OrderSubmittedAt = saved.PDDOrderNo, saved.OrderSubmittedAt
|
||||
if saved.OrderSubmittedAt != nil {
|
||||
r.TimeSource = "existing_unknown"
|
||||
if saved.UnknownResolveRequestID != nil {
|
||||
if strings.HasPrefix(*saved.UnknownResolveRequestID, "backfill:page:") {
|
||||
r.TimeSource = "page"
|
||||
}
|
||||
if strings.HasPrefix(*saved.UnknownResolveRequestID, "backfill:irreversible_at:") {
|
||||
r.TimeSource = "irreversible_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h Handler) BackfillOrders(c *gin.Context) {
|
||||
var req OrderBackfillRequest
|
||||
if !decode(c, &req) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := s.BackfillOrders(c.Request.Context(), req, bearer(c.GetHeader("Authorization")))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func backfillTask(t *testing.T, db *gorm.DB, f fixture, status string) models.PurchaseTask {
|
||||
t.Helper()
|
||||
now := testService(db).Now()
|
||||
task := models.PurchaseTask{TaskType: models.PurchaseTaskTypeStock, ExecutionMode: models.PurchaseExecutionModeLive,
|
||||
Status: status, DeviceID: &f.device.ID, PDDProductID: f.pdd.ID, Quantity: 1, Currency: "CNY",
|
||||
CreateRequestID: uuid.NewString(), RuleSnapshot: string(purchasecontract.DefaultLiveRule()),
|
||||
SpecDecisionSnapshot: `{}`, RequiredCapabilitiesJSON: `[]`, IrreversibleAt: &now,
|
||||
ErrorCode: strptr("ORIGINAL_ERROR"), ErrorMessage: strptr("original failure")}
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
func strptr(s string) *string { return &s }
|
||||
|
||||
func backfillItem(id uint64, order string) OrderBackfillItem {
|
||||
return OrderBackfillItem{AddressSuffix: purchasecontract.AddressSuffix(id), PDDOrderNo: order}
|
||||
}
|
||||
|
||||
func runBackfill(t *testing.T, s *Service, token, requestID string, items ...OrderBackfillItem) []OrderBackfillResult {
|
||||
t.Helper()
|
||||
out, err := s.BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: requestID, Items: items}, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Items) != len(items) || out.RequestID != requestID {
|
||||
t.Fatalf("bad envelope: %+v", out)
|
||||
}
|
||||
return out.Items
|
||||
}
|
||||
|
||||
func loadBackfillTask(t *testing.T, db *gorm.DB, id uint64) models.PurchaseTask {
|
||||
t.Helper()
|
||||
var task models.PurchaseTask
|
||||
if err := db.First(&task, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
func TestOrderBackfillMixedBatchAndReplay(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
a.TaskType, a.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
|
||||
if err := db.Save(&a).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
c := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
if err := db.Model(&c).Update("irreversible_at", nil).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := backfillItem(b.ID, "ORDER-B")
|
||||
page.OrderSubmittedAt = strptr("2026-09-08T20:30:00+08:00")
|
||||
rid := uuid.NewString()
|
||||
items := []OrderBackfillItem{backfillItem(a.ID, "ORDER-A"), {AddressSuffix: "_cg0", PDDOrderNo: "bad"}, page, backfillItem(c.ID, "ORDER-C"), backfillItem(99999, "missing")}
|
||||
results := runBackfill(t, s, f.token, rid, items...)
|
||||
want := []string{"BACKFILLED", CodeBackfillSuffix, "BACKFILLED", CodeOrderTimeMissing, CodeTaskNotFound}
|
||||
for i, r := range results {
|
||||
if r.Code != want[i] || r.Index != i {
|
||||
t.Fatalf("item %d: %+v", i, r)
|
||||
}
|
||||
}
|
||||
if results[0].TimeSource != "irreversible_at" || !results[0].OrderSubmittedAt.Equal(*a.IrreversibleAt) {
|
||||
t.Fatalf("fallback: %+v", results[0])
|
||||
}
|
||||
if results[2].TimeSource != "page" || results[2].OrderSubmittedAt.Format(time.RFC3339) != "2026-09-08T12:30:00Z" {
|
||||
t.Fatalf("page: %+v", results[2])
|
||||
}
|
||||
saved := loadBackfillTask(t, db, a.ID)
|
||||
if saved.StatusVersion != a.StatusVersion+1 || saved.ErrorCode != nil || saved.ErrorMessage != nil || saved.DeviceRunSlot != nil || saved.AccountRunSlot != nil || saved.ActiveSlot == nil || saved.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
t.Fatalf("state metadata: %+v", saved)
|
||||
}
|
||||
if saved.PaymentReviewStatus != a.PaymentReviewStatus || saved.LogisticsStatus != a.LogisticsStatus || saved.WritebackStatus != a.WritebackStatus || saved.RuleSnapshot != a.RuleSnapshot {
|
||||
t.Fatal("unrelated business facts changed")
|
||||
}
|
||||
for _, replayID := range []string{rid, uuid.NewString()} {
|
||||
item := items[0]
|
||||
item.OrderSubmittedAt = strptr("2026-09-09T00:00:00Z")
|
||||
r := runBackfill(t, s, f.token, replayID, item)[0]
|
||||
if r.Result != "already_backfilled" || r.TimeSource != "irreversible_at" {
|
||||
t.Fatalf("replay: %+v", r)
|
||||
}
|
||||
if got := loadBackfillTask(t, db, a.ID); !reflect.DeepEqual(saved, got) {
|
||||
t.Fatal("replay changed persisted task")
|
||||
}
|
||||
}
|
||||
if got := loadBackfillTask(t, db, c.ID); got.PDDOrderNo != nil || got.StatusVersion != c.StatusVersion {
|
||||
t.Fatal("missing time wrote data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillRejectsOwnershipStatesAndInvalidTime(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
for _, status := range []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending, models.PurchaseTaskStatusFailed, models.PurchaseTaskStatusCancelled, models.PurchaseTaskStatusRehearsalCompleted} {
|
||||
task := backfillTask(t, db, f, status)
|
||||
before := loadBackfillTask(t, db, task.ID)
|
||||
r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "ORDER"))[0]
|
||||
if r.Code != CodeStateConflict {
|
||||
t.Fatalf("%s: %+v", status, r)
|
||||
}
|
||||
if got := loadBackfillTask(t, db, task.ID); !reflect.DeepEqual(got, before) {
|
||||
t.Fatal("rejection wrote data")
|
||||
}
|
||||
// Release the fixture's device slot before testing the next running state.
|
||||
if err := task.SetStatus(models.PurchaseTaskStatusCancelled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Save(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
if err := db.Model(&task).Update("device_id", nil).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "ORDER"))[0]
|
||||
if r.Code != CodeBackfillDevice || r.Status != "" || r.PDDOrderNo != nil {
|
||||
t.Fatalf("ownership leaked: %+v", r)
|
||||
}
|
||||
other, err := device.NewService(db).Register(context.Background(), device.RegisterRequest{RequestID: uuid.NewString(), InstallID: uuid.NewString(), Name: "Other", Manufacturer: "Test", Model: "Test", AndroidVersion: "15", AgentVersion: "1", PDDVersion: "7", Capabilities: liveCaps()}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&task).Update("device_id", other.DeviceID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "ORDER"))[0]; r.Code != CodeBackfillDevice {
|
||||
t.Fatalf("cross device: %+v", r)
|
||||
}
|
||||
if err := db.Model(&task).Updates(map[string]any{"device_id": f.device.ID, "execution_mode": models.PurchaseExecutionModeRehearsal}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "ORDER"))[0]; r.Code != CodeStateConflict {
|
||||
t.Fatalf("rehearsal: %+v", r)
|
||||
}
|
||||
if err := db.Model(&task).Update("execution_mode", models.PurchaseExecutionModeLive).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, raw := range []string{"", "2026-09-08 12:00:00", "0001-01-01T00:00:00Z", "garbage"} {
|
||||
item := backfillItem(task.ID, "ORDER")
|
||||
item.OrderSubmittedAt = &raw
|
||||
if r := runBackfill(t, s, f.token, uuid.NewString(), item)[0]; r.Code != CodeOrderTimeInvalid {
|
||||
t.Fatalf("invalid time: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillConflictsNeverOverwrite(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
rid := uuid.NewString()
|
||||
r := runBackfill(t, s, f.token, rid, backfillItem(a.ID, "A"), backfillItem(a.ID, "B"), backfillItem(b.ID, "B"))
|
||||
if r[0].Code != CodeBackfillBatchConflict || r[1].Code != CodeBackfillBatchConflict || r[2].Code != "BACKFILLED" {
|
||||
t.Fatalf("batch: %+v", r)
|
||||
}
|
||||
r = runBackfill(t, s, f.token, rid, backfillItem(a.ID, "B"), backfillItem(b.ID, "C"))
|
||||
if r[0].Code != CodeBackfillOrderUsed || r[1].Code != CodeBackfillOrderConflict {
|
||||
t.Fatalf("changed requestId payload bypassed checks: %+v", r)
|
||||
}
|
||||
if got := loadBackfillTask(t, db, b.ID); *got.PDDOrderNo != "B" || got.StatusVersion != b.StatusVersion+1 {
|
||||
t.Fatal("conflict overwrote")
|
||||
}
|
||||
if got := loadBackfillTask(t, db, a.ID); got.PDDOrderNo != nil {
|
||||
t.Fatal("conflict wrote data")
|
||||
}
|
||||
// Even an unknown task with an existing conflicting value must preserve it.
|
||||
a.PDDOrderNo = strptr("OLD")
|
||||
if err := db.Save(&a).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(a.ID, "NEW"))[0]; r.Code != CodeBackfillOrderConflict {
|
||||
t.Fatalf("unknown existing: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillConcurrentResolveUnknown(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
// SQLite serializes transactions through one connection. These concurrent
|
||||
// service calls verify both winner orders; they do not certify MySQL locks.
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
for i := 0; i < 12; i++ {
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var out OrderBackfillResponse
|
||||
var backErr, manualErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
out, backErr = s.BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(task.ID, "BACK-"+purchasecontract.AddressSuffix(task.ID))}}, f.token)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, _, manualErr = s.ResolveUnknown(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "MANUAL-" + purchasecontract.AddressSuffix(task.ID), OrderSubmittedAt: task.IrreversibleAt})
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
if backErr != nil {
|
||||
t.Fatal(backErr)
|
||||
}
|
||||
got := loadBackfillTask(t, db, task.ID)
|
||||
if got.StatusVersion != task.StatusVersion+1 || got.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
t.Fatal("competing writes changed version twice")
|
||||
}
|
||||
if manualErr == nil {
|
||||
if out.Items[0].Code != CodeBackfillOrderConflict || !strings.HasPrefix(*got.PDDOrderNo, "MANUAL-") {
|
||||
t.Fatalf("manual winner: %+v", out)
|
||||
}
|
||||
} else if code(manualErr) != CodeStateConflict || out.Items[0].Code != "BACKFILLED" || !strings.HasPrefix(*got.PDDOrderNo, "BACK-") {
|
||||
t.Fatalf("backfill winner: %+v %v", out, manualErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillConcurrentLateResultAndOtherTask(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
attempt := models.PurchaseTaskAttempt{TaskID: a.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusFailed, DeviceID: &f.device.ID, RuleSnapshotHash: purchaseRuleSnapshotHash(a.RuleSnapshot), SpecDecisionSnapshot: `{}`}
|
||||
if err := db.Omit("Task").Create(&attempt).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&attempt, attempt.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var out OrderBackfillResponse
|
||||
var backErr, lateErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
out, backErr = s.BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(a.ID, "BACK")}}, f.token)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, lateErr = s.SubmitResult(context.Background(), a.ID, ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: attempt.AttemptID, ResultType: "order_created", PDDOrderNo: "LATE", OrderSubmittedAt: a.IrreversibleAt}, f.token)
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
if backErr != nil || out.Items[0].Code != "BACKFILLED" || code(lateErr) != CodeStateConflict {
|
||||
t.Fatalf("late race: %+v %v %v", out, backErr, lateErr)
|
||||
}
|
||||
var savedAttempt models.PurchaseTaskAttempt
|
||||
if err := db.First(&savedAttempt, attempt.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(savedAttempt, attempt) {
|
||||
t.Fatal("backfill rewrote attempt")
|
||||
}
|
||||
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
_, _, err := s.ResolveUnknown(context.Background(), b.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "BACK", OrderSubmittedAt: b.IrreversibleAt})
|
||||
if err == nil {
|
||||
t.Fatal("manual path assigned another task's order")
|
||||
}
|
||||
if got := loadBackfillTask(t, db, b.ID); got.PDDOrderNo != nil || got.StatusVersion != b.StatusVersion {
|
||||
t.Fatal("other task changed on conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillHTTPBoundary(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/order-backfill", device.RequireAgentHTTPS(false, false), (Handler{DB: db}).BackfillOrders)
|
||||
body, _ := json.Marshal(OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(task.ID, "HTTP")}})
|
||||
for _, test := range []struct {
|
||||
body, token string
|
||||
status int
|
||||
}{
|
||||
{string(body), "", http.StatusUnauthorized},
|
||||
{`{"requestId":"bad","items":[]}`, f.token, http.StatusUnprocessableEntity},
|
||||
{`{"requestId":"x","address":"forbidden"}`, f.token, http.StatusUnprocessableEntity},
|
||||
{string(body), f.token, http.StatusOK},
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/order-backfill", strings.NewReader(test.body))
|
||||
req.Header.Set("Authorization", "Bearer "+test.token)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != test.status {
|
||||
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
_, err := testService(db).BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: make([]OrderBackfillItem, 51)}, f.token)
|
||||
if code(err) != CodeInvalidRequest {
|
||||
t.Fatalf("batch limit: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillConcurrentSameOrderDifferentTasks(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
start := make(chan struct{})
|
||||
results := make(chan OrderBackfillResponse, 2)
|
||||
errors := make(chan error, 2)
|
||||
for _, id := range []uint64{a.ID, b.ID} {
|
||||
go func(id uint64) {
|
||||
<-start
|
||||
out, err := s.BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(id, "SAME")}}, f.token)
|
||||
results <- out
|
||||
errors <- err
|
||||
}(id)
|
||||
}
|
||||
close(start)
|
||||
codes := make(map[string]int)
|
||||
for i := 0; i < 2; i++ {
|
||||
out := <-results
|
||||
if err := <-errors; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
codes[out.Items[0].Code]++
|
||||
}
|
||||
if codes["BACKFILLED"] != 1 || codes[CodeBackfillOrderUsed] != 1 {
|
||||
t.Fatalf("concurrent assignments: %+v", codes)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&models.PurchaseTask{}).Where("pdd_order_no = ?", "SAME").Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("duplicate order: %d %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillRejectsLateAssignmentOfSameOrder(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(a.ID, "SHARED"))[0]; r.Code != "BACKFILLED" {
|
||||
t.Fatal(r)
|
||||
}
|
||||
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderSubmitStarted)
|
||||
lease := s.Now().Add(time.Minute)
|
||||
b.LeaseExpiresAt = &lease
|
||||
if err := db.Save(&b).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attempt := models.PurchaseTaskAttempt{TaskID: b.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusRunning, DeviceID: &f.device.ID, RuleSnapshotHash: purchaseRuleSnapshotHash(b.RuleSnapshot), SpecDecisionSnapshot: `{}`}
|
||||
if err := db.Omit("Task").Create(&attempt).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: attempt.AttemptID, ResultType: "order_created", PDDOrderNo: "SHARED", OrderSubmittedAt: b.IrreversibleAt}
|
||||
out, err := s.SubmitResult(context.Background(), b.ID, req, f.token)
|
||||
if err != nil || out.Status != models.PurchaseTaskStatusOrderResultUnknown {
|
||||
t.Fatalf("conflicting result must commit as unknown: %+v %v", out, err)
|
||||
}
|
||||
got := loadBackfillTask(t, db, b.ID)
|
||||
if got.Status != models.PurchaseTaskStatusOrderResultUnknown || got.StatusVersion != b.StatusVersion+1 || got.PDDOrderNo != nil {
|
||||
t.Fatalf("duplicate assignment was not safely downgraded: %+v", got)
|
||||
}
|
||||
wantMessage := "读到订单号 SHARED,但该号已属于任务 " + taskNumber(a.ID)
|
||||
if got.ErrorCode == nil || *got.ErrorCode != CodeOrderNumberUsed || got.ErrorMessage == nil || *got.ErrorMessage != wantMessage {
|
||||
t.Fatalf("conflict evidence missing: %+v", got)
|
||||
}
|
||||
if got.OrderSubmittedAt == nil || !got.OrderSubmittedAt.Equal(*req.OrderSubmittedAt) || got.IrreversibleAt == nil || got.LeaseExpiresAt != nil || got.DeviceRunSlot != nil || got.AccountRunSlot != nil {
|
||||
t.Fatalf("boundary evidence or released lease missing: %+v", got)
|
||||
}
|
||||
var saved models.PurchaseTaskAttempt
|
||||
if err := db.First(&saved, attempt.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Status != models.PurchaseAttemptStatusFailed || saved.ResultRequestID == nil || *saved.ResultRequestID != req.RequestID || saved.ResultHash == nil || saved.ResultType == nil || *saved.ResultType != "order_created" || saved.FinishedAt == nil || saved.ErrorCode == nil || *saved.ErrorCode != CodeOrderNumberUsed || saved.ErrorMessage == nil || *saved.ErrorMessage != wantMessage {
|
||||
t.Fatalf("attempt result and conflict evidence missing: %+v", saved)
|
||||
}
|
||||
out, err = s.SubmitResult(context.Background(), b.ID, req, f.token)
|
||||
if err != nil || !out.Replayed || out.Status != models.PurchaseTaskStatusOrderResultUnknown || loadBackfillTask(t, db, b.ID).StatusVersion != got.StatusVersion {
|
||||
t.Fatalf("unknown result replay failed: %+v %v", out, err)
|
||||
}
|
||||
owner := loadBackfillTask(t, db, a.ID)
|
||||
if owner.PDDOrderNo == nil || *owner.PDDOrderNo != "SHARED" || owner.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
t.Fatalf("existing owner changed: %+v", owner)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&models.PurchaseTask{}).Where("pdd_order_no = ?", "SHARED").Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("duplicate order: %d %v", count, err)
|
||||
}
|
||||
resolved, _, err := s.ResolveUnknown(context.Background(), b.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "CORRECTED", OrderSubmittedAt: req.OrderSubmittedAt})
|
||||
if err != nil || resolved.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
t.Fatalf("manual resolution unavailable: %+v %v", resolved, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillHTTPTransportPolicy(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, allow := range []string{"false", "true"} {
|
||||
t.Setenv("GOAUTO_ALLOW_INSECURE_AGENT_HTTP", allow)
|
||||
r := gin.New()
|
||||
r.POST("/order-backfill", device.RequireAgentHTTPS(true, false), (Handler{}).BackfillOrders)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/order-backfill", strings.NewReader(`{}`)))
|
||||
if allow == "false" && w.Code != http.StatusUpgradeRequired {
|
||||
t.Fatalf("HTTPS bypass: %d", w.Code)
|
||||
}
|
||||
if allow == "true" && w.Code == http.StatusUpgradeRequired {
|
||||
t.Fatal("HTTP compatibility broken")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillMultiConnectionResolveRace(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(4)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var back OrderBackfillResponse
|
||||
var backErr, manualErr error
|
||||
req := OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(task.ID, "BACK")}}
|
||||
go func() { defer wg.Done(); <-start; back, backErr = s.BackfillOrders(context.Background(), req, f.token) }()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, _, manualErr = s.ResolveUnknown(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "MANUAL", OrderSubmittedAt: task.IrreversibleAt})
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
// SQLite returns table-lock errors rather than waiting on FOR UPDATE.
|
||||
// Only that documented DB contention or a domain conflict is acceptable;
|
||||
// after the competing calls finish, replay must converge without overwrite.
|
||||
if backErr != nil && !strings.Contains(backErr.Error(), "locked") {
|
||||
t.Fatal(backErr)
|
||||
}
|
||||
if manualErr != nil && code(manualErr) != CodeStateConflict && !strings.Contains(manualErr.Error(), "locked") {
|
||||
t.Fatal(manualErr)
|
||||
}
|
||||
if backErr == nil && back.Items[0].Code != "BACKFILLED" && back.Items[0].Code != CodeBackfillOrderConflict && !(back.Items[0].Code == CodeInternal && back.Items[0].Retryable) {
|
||||
t.Fatalf("unexpected race result: %+v", back)
|
||||
}
|
||||
before := loadBackfillTask(t, db, task.ID)
|
||||
replay := runBackfill(t, s, f.token, req.RequestID, req.Items...)[0]
|
||||
after := loadBackfillTask(t, db, task.ID)
|
||||
if before.PDDOrderNo != nil && !reflect.DeepEqual(before, after) {
|
||||
t.Fatal("replay overwrote the concurrent winner")
|
||||
}
|
||||
if after.StatusVersion != task.StatusVersion+1 || after.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
t.Fatal("race did not converge to a single transition")
|
||||
}
|
||||
if manualErr == nil {
|
||||
if *after.PDDOrderNo != "MANUAL" || replay.Code != CodeBackfillOrderConflict {
|
||||
t.Fatal("manual winner overwritten")
|
||||
}
|
||||
} else if *after.PDDOrderNo != "BACK" || (replay.Code != "BACKFILLED" && replay.Code != "ALREADY_BACKFILLED") {
|
||||
t.Fatalf("backfill did not converge: %+v", replay)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestOrderNumberConflictBusinessErrors(t *testing.T) {
|
||||
for _, path := range []string{"resolve_unknown", "cancel", "lifecycle"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
owner := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(owner.ID, "SHARED"))[0]; r.Code != "BACKFILLED" {
|
||||
t.Fatal(r)
|
||||
}
|
||||
status := models.PurchaseTaskStatusOrderResultUnknown
|
||||
if path == "lifecycle" {
|
||||
status = models.PurchaseTaskStatusRunning
|
||||
}
|
||||
task := backfillTask(t, db, f, status)
|
||||
if path != "resolve_unknown" {
|
||||
// Model legacy duplicate data predating the global save guard.
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Updates(map[string]any{"pdd_order_no": "SHARED", "lease_expires_at": s.Now().Add(time.Minute)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
req := ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "SHARED", OrderSubmittedAt: task.IrreversibleAt, Reason: "人工取消"}
|
||||
var err error
|
||||
switch path {
|
||||
case "resolve_unknown":
|
||||
_, _, err = s.ResolveUnknown(context.Background(), task.ID, req)
|
||||
case "cancel":
|
||||
_, _, err = s.Cancel(context.Background(), task.ID, req)
|
||||
case "lifecycle":
|
||||
attempt := models.PurchaseTaskAttempt{TaskID: task.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusRunning, DeviceID: &f.device.ID, RuleSnapshotHash: purchaseRuleSnapshotHash(task.RuleSnapshot), SpecDecisionSnapshot: `{}`}
|
||||
if e := db.Omit("Task").Create(&attempt).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: req.RequestID}, f.token)
|
||||
}
|
||||
want := "订单号已属于任务 " + taskNumber(owner.ID)
|
||||
if code(err) != CodeOrderNumberUsed || err.Error() != want {
|
||||
t.Fatalf("unmapped conflict: %v", err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
writeError(c, err)
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
if e := json.Unmarshal(w.Body.Bytes(), &body); e != nil || w.Code != http.StatusConflict || body.Code != CodeOrderNumberUsed || body.Message != want || body.Retryable {
|
||||
t.Fatalf("unexpected HTTP error: %d %s (%v)", w.Code, w.Body.String(), e)
|
||||
}
|
||||
got := loadBackfillTask(t, db, task.ID)
|
||||
if got.Status != status || got.StatusVersion != task.StatusVersion || got.UnknownResolveRequestID != nil || got.CancelRequestID != nil || got.OrderSubmitRequestID != nil {
|
||||
t.Fatalf("rejected mutation persisted: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
agent := engine.Group("/api/agent/v1/purchase-tasks").Use(device.RequireAgentHTTPS(config.ApplicationConfig.Mode == "prod", trust))
|
||||
agent.GET("", h.AgentHistory)
|
||||
agent.GET("/next", h.Next)
|
||||
agent.POST("/order-backfill", h.BackfillOrders)
|
||||
agent.GET("/:taskId", h.AgentHistoryDetail)
|
||||
agent.POST("/:taskId/retry", h.AgentRetry)
|
||||
agent.POST("/:taskId/reset", h.AgentReset)
|
||||
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
CodeRetryStale = "PURCHASE_RETRY_STALE"
|
||||
CodeSpecReprobeRejected = "PURCHASE_SPEC_REPROBE_REJECTED"
|
||||
CodeOrderResultUnknown = "PURCHASE_ORDER_RESULT_UNKNOWN"
|
||||
CodeOrderNumberUsed = "PURCHASE_ORDER_NUMBER_ALREADY_USED"
|
||||
CodeOrderEmptyTimeout = "PURCHASE_ORDER_EMPTY_TIMEOUT"
|
||||
CodeOrderChooserBack = "PURCHASE_ORDER_CHOOSER_BACK_FAILED"
|
||||
CodeOrderWechatRestore = "PURCHASE_ORDER_WECHAT_RESTORE_FAILED"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package purchasecontract
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseAddressSuffix(t *testing.T) {
|
||||
for _, id := range []uint64{7, 72, ^uint64(0)} {
|
||||
got, err := ParseAddressSuffix(AddressSuffix(id))
|
||||
if err != nil || got != id {
|
||||
t.Fatalf("id=%d got=%d err=%v", id, got, err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"", "_cg", "_cg0", "_cg00", "_cg07", "_cg+7", "_cg-7", "_cg18446744073709551616", "_CG7", "_cg7x", "_cg7_cg72", "address_cg7", " _cg7", "_cg7 ", "_cg7", "_cg7\n"} {
|
||||
if id, err := ParseAddressSuffix(raw); err == nil || id != 0 {
|
||||
t.Errorf("accepted %q: %d", raw, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -360,6 +361,18 @@ func RequiredCapabilities(rule RuleSnapshot) []string {
|
||||
|
||||
func AddressSuffix(taskID uint64) string { return fmt.Sprintf("_cg%d", taskID) }
|
||||
|
||||
// ParseAddressSuffix accepts only the exact canonical suffix, never an address.
|
||||
func ParseAddressSuffix(suffix string) (uint64, error) {
|
||||
if !strings.HasPrefix(suffix, "_cg") {
|
||||
return 0, errors.New("invalid address suffix")
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimPrefix(suffix, "_cg"), 10, 64)
|
||||
if err != nil || id == 0 || AddressSuffix(id) != suffix {
|
||||
return 0, errors.New("invalid address suffix")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func ensureEOF(decoder *json.Decoder) error {
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
|
||||
Reference in New Issue
Block a user