Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1e6812bc4 | ||
|
|
7f1df16b7d | ||
|
|
56f24e1dbc | ||
|
|
433a254239 | ||
|
|
36810f3a71 | ||
|
|
3bf428acd7 | ||
|
|
d8382e6518 | ||
|
|
0edc78a943 | ||
|
|
5e0a9d108c | ||
|
|
08b7095cf1 | ||
|
|
01510a85dc | ||
|
|
4261a542ca | ||
|
|
beec630187 | ||
|
|
2491a857f7 | ||
|
|
0661b2205f | ||
|
|
bb1a410e8a | ||
|
|
8d9c3d47e0 | ||
|
|
368f2c2357 | ||
|
|
f66952f640 | ||
|
|
3a2472dd20 | ||
|
|
741e4bf680 | ||
|
|
b76fb73e12 | ||
|
|
10be37498d | ||
|
|
65d1f34865 | ||
|
|
7e257ca153 | ||
|
|
e89de1a085 | ||
|
|
07a3817591 | ||
|
|
d35d7354d6 | ||
|
|
8c01329f97 | ||
|
|
9194fd664f | ||
|
|
c2426e2671 | ||
|
|
8c1cec4430 | ||
|
|
60c75261d3 | ||
|
|
f634996dad | ||
|
|
57f6cec803 | ||
|
|
0e41000f62 | ||
|
|
c1bd39496d | ||
|
|
87a532f3bb | ||
|
|
8f337d5248 | ||
|
|
03647dd7dd | ||
|
|
7c43dc6029 | ||
|
|
9cdc9f325c | ||
|
|
08d77bd982 | ||
|
|
b2b8c0d1e0 | ||
|
|
1274161a1d | ||
|
|
bb097a6a15 | ||
|
|
2f1e88ea04 | ||
|
|
5b3c8df3bc | ||
|
|
a27db5f960 | ||
|
|
41996f33b2 | ||
|
|
2193762cbd | ||
|
|
174d92a1c9 | ||
|
|
95b5f05ae8 | ||
|
|
750b8763dc | ||
|
|
b4393dac84 | ||
|
|
c1ed3750d2 | ||
|
|
1d01186b8e | ||
|
|
93a175d04f | ||
|
|
12e9aa8591 | ||
|
|
c42e32d49d | ||
|
|
95be6e7569 | ||
|
|
c30b8c4624 | ||
|
|
ec2d86b1a8 | ||
|
|
51265681c2 | ||
|
|
c1eb23e9b1 | ||
|
|
2ddfd52f5d | ||
|
|
c2c0bae056 | ||
|
|
b7babfba7e | ||
|
|
ba0e48bcea | ||
|
|
edd1cb15df | ||
|
|
482ba3408a | ||
|
|
ca7f768a79 | ||
|
|
72b8b5d4d0 | ||
|
|
290a17ea15 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 72
|
||||
versionName = "0.9.59"
|
||||
versionCode = 74
|
||||
versionName = "0.9.61"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -128,9 +128,11 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// 首次启动时仍然弹一次系统授权框(体验最佳位置),但这不是唯一入口:拒绝后
|
||||
// (包括"不再询问")用户可以在"状态"页的相册权限卡片里重新发起授权或跳转应用详情页,
|
||||
// 见 cn.ilapage.goauto.agent.ui.MediaPermissionPolicy / AgentStatusFragment。
|
||||
private fun requestMediaPermission() {
|
||||
val permission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES
|
||||
else Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
val permission = cn.ilapage.goauto.agent.ui.MediaPermissionPolicy.requiredPermission()
|
||||
if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(permission), MEDIA_PERMISSION_REQUEST)
|
||||
}
|
||||
|
||||
+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,119 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import java.math.BigInteger
|
||||
import java.math.BigDecimal
|
||||
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?,
|
||||
val pddOrderAmountCent: Long? = null,
|
||||
)
|
||||
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>()
|
||||
private val amounts = mutableSetOf<Long>()
|
||||
private var invalidAmount = false
|
||||
|
||||
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) }
|
||||
// Treat each accessibility node label independently: a label and an
|
||||
// unrelated price in the next node must never be joined into evidence.
|
||||
text.lineSequence().forEach(::acceptAmountLabel)
|
||||
}
|
||||
|
||||
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)
|
||||
}, amounts.singleOrNull()?.takeUnless { invalidAmount })
|
||||
} else null
|
||||
suffixes.clear()
|
||||
orders.clear()
|
||||
times.clear()
|
||||
amounts.clear()
|
||||
invalidAmount = false
|
||||
return BackfillDetail(item, time)
|
||||
}
|
||||
|
||||
private fun acceptAmountLabel(label: String) {
|
||||
var start = label.indexOf("实付")
|
||||
while (start >= 0) {
|
||||
val ignored = label.substring(0, start).trimEnd().let { it.endsWith("未") || it.endsWith("预计") }
|
||||
if (!ignored) {
|
||||
val candidate = label.substring(start)
|
||||
val match = PAID_AMOUNT.find(candidate)
|
||||
if (match == null || match.range.first != 0) invalidAmount = true
|
||||
else try {
|
||||
amounts += BigDecimal(match.groupValues[1]).movePointRight(2).toBigIntegerExact().longValueExact()
|
||||
} catch (_: ArithmeticException) {
|
||||
invalidAmount = true
|
||||
}
|
||||
}
|
||||
start = label.indexOf("实付", start + 2)
|
||||
}
|
||||
}
|
||||
|
||||
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 PAID_AMOUNT = Regex("实付[ \\t::,,]*(\\d+(?:\\.\\d{1,2})?)[ \\t]*元(?![0-9.])")
|
||||
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()
|
||||
}
|
||||
}
|
||||
+288
-11
@@ -90,7 +90,28 @@ data class VisibleSpecValue(
|
||||
val rawText: String = text,
|
||||
)
|
||||
data class VisibleDimension(val key: String, val name: String, val values: List<VisibleSpecValue>)
|
||||
enum class SpecPanelType { UNKNOWN, NORMAL_SCROLLABLE, NON_SCROLLABLE_CONFIRMATION, QUICK_CONFIRMATION, ORDER_CONFIRMATION }
|
||||
enum class SpecPanelType {
|
||||
UNKNOWN,
|
||||
NORMAL_SCROLLABLE,
|
||||
NON_SCROLLABLE_CONFIRMATION,
|
||||
QUICK_CONFIRMATION,
|
||||
ORDER_CONFIRMATION,
|
||||
/**
|
||||
* #331 fallback: only reached when no stronger panel type matched. It
|
||||
* requires an address entry, a payment-method entry and one quantity input
|
||||
* box, plus at least one auxiliary signal. It is recognition only and never
|
||||
* relaxes any click target (the final order-submit click stays strict).
|
||||
*/
|
||||
REQUIRED_EVIDENCE,
|
||||
}
|
||||
|
||||
/** #331 bounded wait for a spec panel that is still loading after an entry click or an address save. */
|
||||
object SpecPanelStabilityPolicy {
|
||||
/** Upper bound for waiting until the required evidence is present and the structure is stable. */
|
||||
const val WAIT_BOUND_MS = 5_000L
|
||||
/** Sampling interval used after an address save returns to the purchase panel. */
|
||||
const val ADDRESS_SAVE_POLL_MS = 200L
|
||||
}
|
||||
|
||||
object PddSoldOutRecoveryDefaults {
|
||||
const val EXACT_TEXT = "商品已售罄"
|
||||
@@ -134,7 +155,54 @@ data class ParsedPddScreen(
|
||||
val hasCloseControl: Boolean = false,
|
||||
val hasPaymentArea: Boolean = false,
|
||||
val purchaseContextMatched: Boolean = false,
|
||||
// #331 evidence flags. Presence only: address text is never kept here.
|
||||
val hasAddressEntry: Boolean = false,
|
||||
val hasPaymentEntry: Boolean = false,
|
||||
val hasQuantityInput: Boolean = false,
|
||||
val hasQuantityAdjustControls: Boolean = false,
|
||||
val quantityInputCount: Int = 0,
|
||||
/** #331: address entry, payment entry and quantity input share one bounded panel container. */
|
||||
val requiredEvidenceSameContainer: Boolean = false,
|
||||
) {
|
||||
/** The three #331 required spec-panel items are all visible. */
|
||||
val hasRequiredPanelEvidence: Boolean get() = hasAddressEntry && hasPaymentEntry && hasQuantityInput
|
||||
|
||||
/** Boolean/count-only evidence string for diagnostics; contains no page text. */
|
||||
fun requiredEvidenceSummary(): String =
|
||||
"addr=${hasAddressEntry.flag()};pay=${hasPaymentEntry.flag()};qin=$quantityInputCount;" +
|
||||
"adj=${hasQuantityAdjustControls.flag()};summary=${hasSelectionSummary.flag()};" +
|
||||
"close=${hasCloseControl.flag()};submit=${hasOrderSubmitAction.flag()};same=${requiredEvidenceSameContainer.flag()}"
|
||||
|
||||
/**
|
||||
* #331 purchaser-readable hints for missing required items. Only emitted
|
||||
* when the page shows at least one required item or parsed options, so an
|
||||
* unrelated page does not produce misleading environment hints. No page
|
||||
* text or personal data is included.
|
||||
*/
|
||||
fun requiredEvidenceHints(): List<String> {
|
||||
val partial = hasAddressEntry || hasPaymentEntry || hasQuantityInput || panelOptionCount > 0
|
||||
if (!partial) return emptyList()
|
||||
return buildList {
|
||||
if (!hasPaymentEntry) add(MISSING_PAYMENT_ENTRY_HINT)
|
||||
if (!hasAddressEntry) add(MISSING_ADDRESS_ENTRY_HINT)
|
||||
if (!hasQuantityInput) add(MISSING_QUANTITY_INPUT_HINT)
|
||||
if (hasRequiredPanelEvidence && !requiredEvidenceSameContainer) add(NOT_SAME_CONTAINER_HINT)
|
||||
}
|
||||
}
|
||||
|
||||
/** Hints joined for a failure message with a leading separator; empty when there is nothing to hint. */
|
||||
fun requiredEvidenceHintSuffix(): String =
|
||||
requiredEvidenceHints().takeIf { it.isNotEmpty() }?.joinToString(";", prefix = ";") ?: ""
|
||||
|
||||
private fun Boolean.flag(): Int = if (this) 1 else 0
|
||||
|
||||
companion object {
|
||||
const val MISSING_PAYMENT_ENTRY_HINT = "未找到支付入口:请确认 PDD 默认支付方式为微信支付"
|
||||
const val MISSING_ADDRESS_ENTRY_HINT = "未找到收货地址入口:请确认 PDD 已设置默认收货地址"
|
||||
const val MISSING_QUANTITY_INPUT_HINT = "未找到购买数量输入框"
|
||||
const val NOT_SAME_CONTAINER_HINT = "收货地址入口、支付入口和购买数量输入框不在同一规格面板内"
|
||||
}
|
||||
|
||||
fun isTransientSoldOut(
|
||||
exactText: String,
|
||||
fallbackTopText: String = "相似商品",
|
||||
@@ -186,6 +254,13 @@ object PddScreenParser {
|
||||
// purchase/order/payment controls must never become collection click targets.
|
||||
private val nonConfigurableClickDenylist = listOf("提交订单", "确认订单", "支付", "付款")
|
||||
|
||||
/** Masked CN mobile number shown in the purchase address row, e.g. 138****5678. Presence only. */
|
||||
val MASKED_PHONE_PATTERN = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
|
||||
/** An address/payment entry must be clickable itself or through a nearby row container. */
|
||||
private const val ENTRY_CLICKABLE_ANCESTOR_MAX_DEPTH = 6
|
||||
/** Whole-sheet containers are clickable too; a real entry row stays well below this share of the screen height. */
|
||||
private const val ENTRY_ROW_MAX_HEIGHT_PERCENT = 30
|
||||
|
||||
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null): ParsedPddScreen {
|
||||
val visibleNodes = snapshot.nodes.filter { it.visible }
|
||||
val visible = visibleNodes.mapNotNull { node ->
|
||||
@@ -203,6 +278,8 @@ object PddScreenParser {
|
||||
}
|
||||
}
|
||||
val labels = visible.map(SnapshotNode::label)
|
||||
val visibleByPath = visible.associateBy(SnapshotNode::path)
|
||||
val sourceByPath = visibleNodes.associateBy(SnapshotNode::path)
|
||||
val textAliases = config.textAliases
|
||||
val problem = PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels)
|
||||
val compactLabels = labels.map { it.replace(" ", "") }
|
||||
@@ -240,7 +317,23 @@ object PddScreenParser {
|
||||
val decreaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityDecreaseAliases }
|
||||
val increaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityIncreaseAliases }
|
||||
val hasQuantityControls = quantityInputs.size == 1 && decreaseControls.size == 1 && increaseControls.size == 1
|
||||
// #331: the unique input box is required; the +/- buttons are only auxiliary.
|
||||
val hasQuantityInput = quantityInputs.size == 1 && quantityInputs.single().enabled
|
||||
val hasQuantityAdjustControls = decreaseControls.size == 1 && increaseControls.size == 1
|
||||
val hasPaymentArea = compactLabels.any { label -> textAliases.specPanel.paymentAreaAliases.any(label::contains) }
|
||||
val addressEntryRows = addressEntryRows(visibleNodes, screenHeight)
|
||||
val paymentEntryRows = paymentEntryRows(visibleNodes, screenHeight, textAliases.specPanel.paymentAreaAliases)
|
||||
val hasAddressEntry = addressEntryRows.isNotEmpty()
|
||||
val hasPaymentEntry = paymentEntryRows.isNotEmpty()
|
||||
// #331: the three required items must sit in one panel container, not
|
||||
// merely somewhere on screen (e.g. an address dialog, a payment dialog
|
||||
// and an unrelated input box). Same area bound idea as boundedScrollables.
|
||||
val requiredEvidenceSameContainer = hasAddressEntry && hasPaymentEntry && hasQuantityInput &&
|
||||
addressEntryRows.any { address ->
|
||||
paymentEntryRows.any { payment ->
|
||||
sharesBoundedPanelContainer(listOf(address, payment, quantityInputs.single()), sourceByPath, screenArea)
|
||||
}
|
||||
}
|
||||
val hasQuickBuy = visible.any { node ->
|
||||
node.label.replace(" ", "").let { compact -> textAliases.specPanel.quickBuyAliases.any(compact::contains) } &&
|
||||
node.bounds.centerY.toDouble() >= screenHeight * 0.75
|
||||
@@ -276,27 +369,50 @@ object PddScreenParser {
|
||||
headings.forEachIndexed { index, heading ->
|
||||
val lower = headings.getOrNull(index + 1)?.bounds?.top ?: Int.MAX_VALUE
|
||||
val dimensionKey = dimensionKey(heading.label, config, any { it.key == "color" })
|
||||
val values = panelVisible.asSequence()
|
||||
.filter { it.clickable && it.bounds.top >= heading.bounds.bottom && it.bounds.bottom <= lower }
|
||||
.filter { it.bounds.width > 0 && it.bounds.height > 0 && it.label.length <= 80 }
|
||||
fun inValueRegion(node: SnapshotNode) =
|
||||
node.bounds.top >= heading.bounds.bottom && node.bounds.bottom <= lower &&
|
||||
node.bounds.width > 0 && node.bounds.height > 0 && node.label.length <= 80
|
||||
val candidates = panelVisible.asSequence()
|
||||
.filter { it.clickable && inValueRegion(it) }
|
||||
.filterNot { isExactHeadingLabel(it.label, config) }
|
||||
.filterNot { node ->
|
||||
isExcludedOptionLabel(node.label) ||
|
||||
descendants(node, visibleNodes).any { descendant -> isExcludedOptionLabel(descendant.label) }
|
||||
}
|
||||
.map {
|
||||
val rawText = it.label
|
||||
.toList()
|
||||
// #332/task 535: one option block may expose several clickable
|
||||
// nodes (outer block, image, inner text with a badge such as
|
||||
// "零差评"). Nodes nested in the same labelled option block are
|
||||
// collapsed into one value named by the outer block. The click
|
||||
// node is still chosen by safeOptionRank among the members.
|
||||
val values = candidates
|
||||
.groupBy { candidate ->
|
||||
optionBlock(candidate, candidates, visibleByPath, sourceByPath, ::inValueRegion)?.path ?: candidate.path
|
||||
}
|
||||
.map { (blockPath, members) ->
|
||||
val block = visibleByPath[blockPath]
|
||||
val chosen = members.minBy { option -> safeOptionRank(option, visibleNodes) }
|
||||
val selected = members.any(SnapshotNode::selected) || block?.selected == true
|
||||
val checked = members.any(SnapshotNode::checked) || block?.checked == true
|
||||
Triple(chosen.copy(selected = selected, checked = checked), block?.label ?: chosen.label, members)
|
||||
}
|
||||
.asSequence()
|
||||
.map { (option, blockLabel, members) ->
|
||||
val rawText = blockLabel
|
||||
val normalizedText = when (dimensionKey) {
|
||||
"color" -> SpecValueNormalizer.normalizeColor(rawText)
|
||||
"size" -> SpecValueNormalizer.normalizeSize(rawText)
|
||||
else -> rawText
|
||||
}
|
||||
val stateText = (listOf(it.label) + descendants(it, visibleNodes).map(SnapshotNode::label)).joinToString(" ")
|
||||
val stateText = (listOf(option.label) + descendants(option, visibleNodes).map(SnapshotNode::label)).joinToString(" ")
|
||||
VisibleSpecValue(
|
||||
normalizedText,
|
||||
it.enabled && !stateText.containsUnavailableWord(),
|
||||
it,
|
||||
if (dimensionKey == "color") colorImageBoundsForOption(it, visibleNodes) else null,
|
||||
option.enabled && !stateText.containsUnavailableWord(),
|
||||
option,
|
||||
if (dimensionKey == "color") {
|
||||
(listOf(option) + members.filter { it.path != option.path })
|
||||
.firstNotNullOfOrNull { colorImageBoundsForOption(it, visibleNodes) }
|
||||
} else null,
|
||||
rawText,
|
||||
)
|
||||
}
|
||||
@@ -339,6 +455,14 @@ object PddScreenParser {
|
||||
panelScrollable == null && headings.size >= 2 && dimensions.size >= 2 &&
|
||||
dimensions.sumOf { it.values.size } >= 2 &&
|
||||
hasQuantityControls && hasOrderSubmitAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
// #331 fallback, evaluated only after every stronger branch failed.
|
||||
// Required: address entry + payment entry + one enabled quantity
|
||||
// input. Auxiliary (at least one): grouped options, selection
|
||||
// summary, close control, +/- controls or the lower submit action.
|
||||
requiredEvidenceSameContainer && (
|
||||
dimensions.any { it.values.isNotEmpty() } || hasSelectionSummary || hasClose ||
|
||||
hasQuantityAdjustControls || hasOrderSubmitAction
|
||||
) -> SpecPanelType.REQUIRED_EVIDENCE
|
||||
else -> SpecPanelType.UNKNOWN
|
||||
}
|
||||
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
|
||||
@@ -439,6 +563,12 @@ object PddScreenParser {
|
||||
purchaseContextMatched = continuedPurchasePanel,
|
||||
hasQuantityControls = hasQuantityControls,
|
||||
hasOrderSubmitAction = hasOrderSubmitAction,
|
||||
hasAddressEntry = hasAddressEntry,
|
||||
hasPaymentEntry = hasPaymentEntry,
|
||||
hasQuantityInput = hasQuantityInput,
|
||||
hasQuantityAdjustControls = hasQuantityAdjustControls,
|
||||
quantityInputCount = quantityInputs.size,
|
||||
requiredEvidenceSameContainer = requiredEvidenceSameContainer,
|
||||
explicitSpecEntryCount = explicitSpecEntries.size,
|
||||
nestedSpecEntryCount = nestedSpecEntries.size,
|
||||
bottomPurchaseEntryCount = bottomSpecEntries.size,
|
||||
@@ -453,6 +583,93 @@ object PddScreenParser {
|
||||
return compact in excludedExactOptionLabels || excludedOptionWords.any { compact.contains(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Outermost labelled clickable ancestor inside the value region whose label
|
||||
* prefixes every candidate nested in it. Rows that contain several distinct
|
||||
* options never qualify because their members do not share its label.
|
||||
*/
|
||||
private fun optionBlock(
|
||||
candidate: SnapshotNode,
|
||||
candidates: List<SnapshotNode>,
|
||||
visibleByPath: Map<String, SnapshotNode>,
|
||||
sourceByPath: Map<String, SnapshotNode>,
|
||||
inValueRegion: (SnapshotNode) -> Boolean,
|
||||
): SnapshotNode? {
|
||||
fun compact(value: String) = value.replace(Regex("\\s+"), "")
|
||||
var block: SnapshotNode? = null
|
||||
var parentPath = candidate.parentPath
|
||||
while (parentPath != null) {
|
||||
val raw = sourceByPath[parentPath] ?: break
|
||||
val resolved = visibleByPath[parentPath]
|
||||
// Only a block with its own label names the option; a blank row
|
||||
// container resolved from its first child never merges options.
|
||||
if (raw.clickable && raw.label.isNotBlank() && resolved != null && inValueRegion(resolved)) {
|
||||
val outer = compact(resolved.label)
|
||||
val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") }
|
||||
if (outer.isEmpty() || members.any { !compact(it.label).startsWith(outer) }) break
|
||||
block = resolved
|
||||
}
|
||||
parentPath = raw.parentPath
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
/** The nearby clickable row holding [node], or null when there is none or it is not a plausible entry row. */
|
||||
private fun clickableEntryRow(node: SnapshotNode, sourceByPath: Map<String, SnapshotNode>, screenHeight: Int): SnapshotNode? {
|
||||
var current: SnapshotNode? = node
|
||||
var depth = 0
|
||||
while (current != null && depth <= ENTRY_CLICKABLE_ANCESTOR_MAX_DEPTH) {
|
||||
if (current.clickable) {
|
||||
val plausible = current.visible && current.enabled && current.bounds.width > 0 && current.bounds.height > 0 &&
|
||||
(screenHeight <= 0 || current.bounds.height * 100 <= screenHeight * ENTRY_ROW_MAX_HEIGHT_PERCENT)
|
||||
return current.takeIf { plausible }
|
||||
}
|
||||
current = current.parentPath?.let(sourceByPath::get)
|
||||
depth++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Address rows: a masked phone inside a nearby clickable row. The address text is never read out. */
|
||||
private fun addressEntryRows(source: List<SnapshotNode>, screenHeight: Int): List<SnapshotNode> {
|
||||
val byPath = source.associateBy(SnapshotNode::path)
|
||||
return source.filter { node ->
|
||||
node.visible && node.bounds.width > 0 && node.bounds.height > 0 && MASKED_PHONE_PATTERN.containsMatchIn(node.label)
|
||||
}.mapNotNull { clickableEntryRow(it, byPath, screenHeight) }.distinctBy(SnapshotNode::path)
|
||||
}
|
||||
|
||||
/** Payment-method rows via the configurable payment aliases inside a nearby clickable row. */
|
||||
private fun paymentEntryRows(source: List<SnapshotNode>, screenHeight: Int, aliases: List<String>): List<SnapshotNode> {
|
||||
val byPath = source.associateBy(SnapshotNode::path)
|
||||
return source.filter { node ->
|
||||
node.visible && node.bounds.width > 0 && node.bounds.height > 0 &&
|
||||
node.label.replace(Regex("\\s+"), "").let { compact -> aliases.any(compact::contains) }
|
||||
}.mapNotNull { clickableEntryRow(it, byPath, screenHeight) }.distinctBy(SnapshotNode::path)
|
||||
}
|
||||
|
||||
/**
|
||||
* #331: true when the lowest common ancestor of [nodes] is a real panel
|
||||
* container: it is not a window root (a node without parent) and, like
|
||||
* boundedScrollables, it does not cover the whole screen. Items taken from
|
||||
* separate dialogs/pages only meet at the root or a full-screen wrapper.
|
||||
*/
|
||||
private fun sharesBoundedPanelContainer(nodes: List<SnapshotNode>, sourceByPath: Map<String, SnapshotNode>, screenArea: Long): Boolean {
|
||||
fun chain(node: SnapshotNode): List<SnapshotNode> {
|
||||
val result = mutableListOf(node)
|
||||
var current = node.parentPath?.let(sourceByPath::get)
|
||||
while (current != null) {
|
||||
result += current
|
||||
current = current.parentPath?.let(sourceByPath::get)
|
||||
}
|
||||
return result
|
||||
}
|
||||
val others = nodes.drop(1).map { node -> chain(node).map(SnapshotNode::path).toSet() }
|
||||
val common = chain(nodes.first()).firstOrNull { candidate -> others.all { candidate.path in it } } ?: return false
|
||||
val parent = common.parentPath?.let(sourceByPath::get) ?: return false
|
||||
if (parent.path == common.path || common.bounds.width <= 0 || common.bounds.height <= 0) return false
|
||||
return screenArea == 0L || common.bounds.width.toLong() * common.bounds.height < screenArea
|
||||
}
|
||||
|
||||
private fun orderSubmitCandidatesAncestorFree(candidate: SnapshotNode, source: List<SnapshotNode>, aliases: List<String>): Boolean {
|
||||
val candidatePrefix = "${candidate.path}/"
|
||||
return source.none { node ->
|
||||
@@ -1385,6 +1602,41 @@ class PddProductDetailCollector(
|
||||
.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* #334: the panel already shows its topmost color heading (the first
|
||||
* parsed dimension is "color") with values visible, so no restore swipe
|
||||
* is required. Swiping an already-top panel drags the bottom sheet
|
||||
* further and can make the color/size headings vanish.
|
||||
*/
|
||||
private fun isSpecPanelAtTop(screen: ParsedPddScreen): Boolean {
|
||||
val first = screen.dimensions.firstOrNull() ?: return false
|
||||
return first.key == "color" && first.values.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun recordSpecPanelTopSwipe(
|
||||
reason: AgentDiagnosticReason,
|
||||
screen: ParsedPddScreen,
|
||||
swipeCount: Int,
|
||||
headingCountBefore: Int?,
|
||||
headingCountAfter: Int?,
|
||||
) {
|
||||
if (taskId <= 0) return
|
||||
diagnostic(
|
||||
AgentDiagnosticEvent(
|
||||
taskId = taskId,
|
||||
// A dedicated PAGE_STABILITY event on purpose: SPEC_PANEL_ENTRY
|
||||
// already carries the panel-open/restore diagnostics elsewhere,
|
||||
// and several tests assert a single SPEC_PANEL_ENTRY event per run.
|
||||
stage = AgentDiagnosticStage.PAGE_STABILITY,
|
||||
reason = reason,
|
||||
attempt = swipeCount,
|
||||
targetClassName = "type=${screen.specPanelType.name};headBefore=${headingCountBefore ?: -1};" +
|
||||
"headAfter=${headingCountAfter ?: -1};dim=${screen.dimensions.sumOf { it.values.size }}",
|
||||
clickableAncestorDepth = screen.panelHeadingCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun moveSpecPanelToTop(
|
||||
goodsId: String,
|
||||
config: PddCollectorConfig,
|
||||
@@ -1394,17 +1646,40 @@ class PddProductDetailCollector(
|
||||
): PddCollectorResult? {
|
||||
var previous: List<String>? = null
|
||||
var stable = 0
|
||||
repeat(config.limits.getValue("specVerticalSwipes")) {
|
||||
var priorHeadingCount = -1
|
||||
var priorDimensionCount = -1
|
||||
var swipes = 0
|
||||
repeat(config.limits.getValue("specVerticalSwipes")) { pass ->
|
||||
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
|
||||
val screen = parse(goodsId, config, evidence)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
if (pass == 0 && isSpecPanelAtTop(screen)) {
|
||||
recordSpecPanelTopSwipe(AgentDiagnosticReason.SPEC_PANEL_TOP_ALREADY, screen, swipes, null, screen.panelHeadingCount)
|
||||
return null
|
||||
}
|
||||
val headingCount = screen.panelHeadingCount
|
||||
val dimensionCount = screen.dimensions.sumOf { it.values.size }
|
||||
// A normal scroll-to-top can legitimately drop the panel's
|
||||
// heading count (lower dimensions scroll out of view) without
|
||||
// losing any values, so heading count alone must never trigger
|
||||
// this. Only a full loss of every collected spec value, or the
|
||||
// panel no longer being recognized as open, indicates the sheet
|
||||
// was dragged too far.
|
||||
val panelNoLongerRecognized = !screen.specPanelOpen || screen.specPanelType == SpecPanelType.UNKNOWN
|
||||
if (pass > 0 && priorDimensionCount > 0 && (dimensionCount == 0 || panelNoLongerRecognized)) {
|
||||
recordSpecPanelTopSwipe(AgentDiagnosticReason.SPEC_PANEL_TOP_COLLAPSED, screen, swipes, priorHeadingCount, headingCount)
|
||||
return failure("SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
|
||||
}
|
||||
priorHeadingCount = headingCount
|
||||
priorDimensionCount = dimensionCount
|
||||
val signature = viewportSignature(screen)
|
||||
stable = if (previous != null && signature == previous) stable + 1 else 0
|
||||
previous = signature
|
||||
if (stable >= config.limits.getValue("stableEdgeReads")) return null
|
||||
val anchor = screen.dimensions.flatMap { it.values }.firstOrNull()?.node ?: specPanelContainer ?: return null
|
||||
if (!driver.swipeSpec(SwipeDirection.DOWN, anchor)) return null
|
||||
swipes++
|
||||
pause(350)
|
||||
}
|
||||
return null
|
||||
@@ -1572,6 +1847,8 @@ class PddProductDetailCollector(
|
||||
"type=${it.specPanelType.name};scroll=${it.panelScrollableCount};head=${it.panelHeadingCount};" +
|
||||
"option=${it.panelOptionCount};summary=${it.hasSelectionSummary.toInt()};" +
|
||||
"quantity=${it.hasQuantityControls.toInt()};submit=${it.hasOrderSubmitAction.toInt()};" +
|
||||
"addr=${it.hasAddressEntry.toInt()};pay=${it.hasPaymentEntry.toInt()};qin=${it.quantityInputCount};" +
|
||||
"adj=${it.hasQuantityAdjustControls.toInt()};close=${it.hasCloseControl.toInt()};" +
|
||||
"changed=${screenChanged.toInt()};" +
|
||||
"recovery=${when {
|
||||
recoverySucceeded -> "success"
|
||||
|
||||
+151
-7
@@ -1,24 +1,65 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import cn.ilapage.goauto.agent.network.ImageSearchImage
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
|
||||
data class PreparedImageSearchAsset(val uri: Uri)
|
||||
|
||||
/**
|
||||
* 图搜参考图的纯校验与缩放决策逻辑(#277),与 Android 框架解耦以便 JVM 单测覆盖。
|
||||
* MediaStore 读写、真机相册顺序等只能真机验证,见 [PinduoduoImageSearchAssetStore]。
|
||||
*/
|
||||
object ImageSearchAssetPolicy {
|
||||
private const val MAX_BYTES = 10 * 1024 * 1024
|
||||
const val MAX_BYTES = 20 * 1024 * 1024
|
||||
const val MIN_SEARCH_IMAGE_EDGE = 960
|
||||
const val MAX_SEARCH_IMAGE_EDGE = 4_096
|
||||
const val SEARCH_JPEG_QUALITY = 95
|
||||
|
||||
fun valid(bytes: ByteArray, reference: ImageSearchImage): Boolean {
|
||||
if (bytes.isEmpty() || bytes.size.toLong() != reference.sizeBytes || bytes.size > MAX_BYTES) return false
|
||||
if (reference.mediaType != "image/jpeg" && reference.mediaType != "image/png") return false
|
||||
if (reference.mediaType == "image/jpeg" && !hasJpegMarkers(bytes)) return false
|
||||
return sha256(bytes) == reference.sha256.lowercase()
|
||||
}
|
||||
fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
|
||||
fun hasJpegMarkers(bytes: ByteArray): Boolean =
|
||||
bytes.size >= 4 &&
|
||||
bytes[0] == 0xff.toByte() &&
|
||||
bytes[1] == 0xd8.toByte() &&
|
||||
bytes[bytes.size - 2] == 0xff.toByte() &&
|
||||
bytes[bytes.size - 1] == 0xd9.toByte()
|
||||
|
||||
fun sha256(bytes: ByteArray): String =
|
||||
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
|
||||
/**
|
||||
* 目标缩放尺寸,纯整数运算,便于单测覆盖临界值。
|
||||
* 已经不小于 [MIN_SEARCH_IMAGE_EDGE] 时返回 null(原图直接使用)。
|
||||
* 放大后超过 [MAX_SEARCH_IMAGE_EDGE] 时也返回 null(放弃放大,交由调用方决定是否使用原图或失败)。
|
||||
*/
|
||||
fun targetScaledSize(width: Int, height: Int): Pair<Int, Int>? {
|
||||
if (width <= 0 || height <= 0) return null
|
||||
if (width >= MIN_SEARCH_IMAGE_EDGE && height >= MIN_SEARCH_IMAGE_EDGE) return null
|
||||
val scale = maxOf(
|
||||
MIN_SEARCH_IMAGE_EDGE.toDouble() / width,
|
||||
MIN_SEARCH_IMAGE_EDGE.toDouble() / height,
|
||||
)
|
||||
val targetWidth = (width * scale).toInt()
|
||||
val targetHeight = (height * scale).toInt()
|
||||
if (targetWidth > MAX_SEARCH_IMAGE_EDGE || targetHeight > MAX_SEARCH_IMAGE_EDGE) return null
|
||||
return targetWidth to targetHeight
|
||||
}
|
||||
}
|
||||
|
||||
class PinduoduoImageSearchAssetStore(context: Context) {
|
||||
@@ -26,19 +67,122 @@ class PinduoduoImageSearchAssetStore(context: Context) {
|
||||
|
||||
fun prepare(bytes: ByteArray, reference: ImageSearchImage): PreparedImageSearchAsset? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || !ImageSearchAssetPolicy.valid(bytes, reference)) return null
|
||||
deleteStalePreparedImages()
|
||||
val searchBytes = prepareSearchCopy(bytes) ?: return null
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, "goauto-search-${UUID.randomUUID()}.jpg")
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, "$DISPLAY_NAME_PREFIX${UUID.randomUUID()}.jpg")
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/GoAutoSearch")
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, MEDIA_DIRECTORY)
|
||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return null
|
||||
return try {
|
||||
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: error("image output unavailable")
|
||||
val wrote = resolver.openOutputStream(uri)?.use { it.write(searchBytes) } != null
|
||||
if (!wrote) {
|
||||
resolver.delete(uri, null, null)
|
||||
return null
|
||||
}
|
||||
resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null)
|
||||
resolver.notifyChange(uri, null)
|
||||
Thread.sleep(MEDIA_INDEX_SETTLE_MILLIS)
|
||||
PreparedImageSearchAsset(uri)
|
||||
} catch (_: Exception) { resolver.delete(uri, null, null); null }
|
||||
} catch (_: Exception) {
|
||||
resolver.delete(uri, null, null)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(asset: PreparedImageSearchAsset?) { asset?.let { resolver.delete(it.uri, null, null) } }
|
||||
/**
|
||||
* 本方案不做图像识别,只保证自己写入的图片是相册中最新的一项,从而在 PDD 图搜页"最近项目"
|
||||
* 网格里稳定命中第一格。
|
||||
*
|
||||
* `[坑②修复]` 真机实测该网格同时包含图片和视频(观察到带时长文案的视频格子)。早前实现只在
|
||||
* `MediaStore.Images` 里比较 jpeg,若相册里有更新的视频,网格第一格就不是我们写入的图,会导致
|
||||
* 点错图。这里改为查询 `MediaStore.Files`(图片 + 视频),把结果交给纯函数
|
||||
* [ImageSearchMediaRecencyPolicy] 判定;只要存在按 (DATE_ADDED, _ID) 排序更靠前的记录,
|
||||
* 就返回 false(调用方据此明确失败,不猜测、不往后找)。真机验证未执行。
|
||||
*/
|
||||
/**
|
||||
* 确认我们写入的图是相册中最新的一项(图片与视频一起比较)。
|
||||
*
|
||||
* `[必须]` 用经典的 sortOrder 字符串,不要用 Bundle 的 QUERY_ARG_SORT_COLUMNS /
|
||||
* QUERY_ARG_LIMIT。2026-09-15 真机对比:OnePlus PLY110(Android 16 / SDK 36)
|
||||
* 认 Bundle 参数,三星 SM-G9700(Android 12 / SDK 31)不认——排序被忽略后
|
||||
* LIMIT 取回的是任意 5 行,我们刚写入的图根本不在其中,于是判定"不是最新"
|
||||
* 并以 IMAGE_SEARCH_ASSET_NOT_LATEST 失败,实际相册里最新的照片还是两天前的。
|
||||
*
|
||||
* LIMIT 不能写进 sortOrder:Android 11 起 MediaProvider 会以
|
||||
* "Invalid token LIMIT" 拒绝。改为按排序游标读前 [RECENCY_QUERY_LIMIT] 行。
|
||||
*/
|
||||
fun isMostRecent(asset: PreparedImageSearchAsset): Boolean {
|
||||
val ownId = runCatching { ContentUris.parseId(asset.uri) }.getOrNull() ?: return false
|
||||
val filesUri = MediaStore.Files.getContentUri("external")
|
||||
val selection = "${MediaStore.Files.FileColumns.MEDIA_TYPE} IN (?, ?)"
|
||||
val selectionArgs = arrayOf(
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(),
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString(),
|
||||
)
|
||||
val sortOrder =
|
||||
"${MediaStore.Files.FileColumns.DATE_ADDED} DESC, ${MediaStore.Files.FileColumns._ID} DESC"
|
||||
val entries = runCatching {
|
||||
resolver.query(
|
||||
filesUri,
|
||||
arrayOf(MediaStore.Files.FileColumns._ID, MediaStore.Files.FileColumns.DATE_ADDED),
|
||||
selection,
|
||||
selectionArgs,
|
||||
sortOrder,
|
||||
)?.use { cursor ->
|
||||
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
|
||||
val dateIndex = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_ADDED)
|
||||
val result = mutableListOf<ImageSearchMediaEntry>()
|
||||
while (result.size < RECENCY_QUERY_LIMIT && cursor.moveToNext()) {
|
||||
result += ImageSearchMediaEntry(cursor.getLong(idIndex), cursor.getLong(dateIndex))
|
||||
}
|
||||
result
|
||||
}
|
||||
}.getOrNull() ?: return false
|
||||
return ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId)
|
||||
}
|
||||
|
||||
fun delete(asset: PreparedImageSearchAsset?) {
|
||||
asset ?: return
|
||||
runCatching { resolver.delete(asset.uri, null, null) }
|
||||
}
|
||||
|
||||
private fun deleteStalePreparedImages() {
|
||||
runCatching {
|
||||
resolver.delete(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
"${MediaStore.Images.Media.RELATIVE_PATH} = ? AND ${MediaStore.Images.Media.DISPLAY_NAME} LIKE ?",
|
||||
arrayOf("$MEDIA_DIRECTORY/", "$DISPLAY_NAME_PREFIX%.jpg"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareSearchCopy(source: ByteArray): ByteArray? {
|
||||
val bitmap = BitmapFactory.decodeByteArray(source, 0, source.size) ?: return null
|
||||
val target = ImageSearchAssetPolicy.targetScaledSize(bitmap.width, bitmap.height)
|
||||
if (target == null) {
|
||||
bitmap.recycle()
|
||||
return source
|
||||
}
|
||||
val (targetWidth, targetHeight) = target
|
||||
val scaled = Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true)
|
||||
if (scaled !== bitmap) bitmap.recycle()
|
||||
return try {
|
||||
ByteArrayOutputStream().use { output ->
|
||||
if (!scaled.compress(Bitmap.CompressFormat.JPEG, ImageSearchAssetPolicy.SEARCH_JPEG_QUALITY, output)) return null
|
||||
output.toByteArray()
|
||||
}
|
||||
} finally {
|
||||
scaled.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MEDIA_DIRECTORY = "Pictures/GoAutoSearch"
|
||||
const val DISPLAY_NAME_PREFIX = "goauto-search-"
|
||||
const val MEDIA_INDEX_SETTLE_MILLIS = 750L
|
||||
const val RECENCY_QUERY_LIMIT = 5
|
||||
}
|
||||
}
|
||||
|
||||
+491
@@ -0,0 +1,491 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
/**
|
||||
* PDD 图搜状态机(#277)。
|
||||
*
|
||||
* 拆成两层:
|
||||
* - 本文件顶部的纯判定对象([ImageSearchRecoveryPolicy]、[ImageSearchCandidatePolicy]、
|
||||
* [ImageSearchMediaRecencyPolicy])不依赖 AccessibilityNodeInfo/MediaStore,可在 JVM
|
||||
* 单测中直接覆盖。
|
||||
* - [PinduoduoImageSearchAutomation] 是实际驱动 [PddCollectorDriver] 的执行器,只能真机验证。
|
||||
*
|
||||
* 归位状态机思路参考本机 D:\chengma\cmroubao_old 的
|
||||
* PinduoduoImageSearchAutomation.kt(未整包复制,只借鉴"归位到入口,上限次数内不断重试"的结构)。
|
||||
* OCR/VLM 相关文件(PinduoduoGoodsIdOcr / PinduoduoCheckoutOcr / PinduoduoSpecificationVlm)
|
||||
* 未参考、未移植。
|
||||
*/
|
||||
enum class ImageSearchPageKind {
|
||||
HOME,
|
||||
IMAGE_SEARCH_ENTRY,
|
||||
IMAGE_SEARCH_RESULTS,
|
||||
RETRY_DIALOG,
|
||||
ORDER_CONFIRM,
|
||||
OTHER_PDD,
|
||||
NON_PDD,
|
||||
}
|
||||
|
||||
enum class ImageSearchRecoveryAction {
|
||||
/** 已经在图搜入口页,无需再动作。 */
|
||||
NONE,
|
||||
/** 在首页,点击"拍照搜索"入口(唯一可点节点)。 */
|
||||
CLICK_CAMERA,
|
||||
/**
|
||||
* 关闭"再试一次"弹窗。
|
||||
*
|
||||
* `[必须]` 这个弹窗按返回键**无效**——2026-09-15 真机实测,按 BACK 前后两次
|
||||
* uiautomator dump 逐字节相同。唯一出口是点"取消",而点"取消"会退出图搜、
|
||||
* 回到拼多多首页,所以关闭之后仍要重新走一遍入口。
|
||||
*/
|
||||
DISMISS_RETRY_DIALOG,
|
||||
|
||||
/** 在结果页/订单确认页/其它拼多多页面,按返回逐步归位。订单确认页只允许返回,
|
||||
* 绝不点击任何支付/下单控件。 */
|
||||
BACK,
|
||||
/** 完全不在拼多多前台,需要先拉起应用。 */
|
||||
LAUNCH_PDD,
|
||||
}
|
||||
|
||||
/**
|
||||
* 归位状态机的纯判定逻辑:给定当前页面的包名、可见文案、排序控件数量,判断处于哪种页面形态,
|
||||
* 以及归位到图搜入口还需要做什么动作。
|
||||
*/
|
||||
object ImageSearchRecoveryPolicy {
|
||||
/** 归位动作上限,参考 cmroubao_old 的经验值,防止死循环。 */
|
||||
const val MAX_RECOVERY_ACTIONS = 50
|
||||
|
||||
/**
|
||||
* 允许关闭"再试一次"弹窗并重新进入的次数上限。
|
||||
*
|
||||
* `[必须]` 进入图搜页后拼多多的摄像头会实时识别,识别不到东西约 4 秒后自己弹出
|
||||
* 该弹窗(2026-09-15 真机实测:+3s 无弹窗、+5s 有)。关闭后会回到首页,重新进入
|
||||
* 又会撞上同一个窗口,因此必须单独设上限,否则会在通用的 50 次预算里空转数分钟。
|
||||
*/
|
||||
const val MAX_RETRY_DIALOG_RECOVERIES = 3
|
||||
|
||||
/**
|
||||
* 首页搜索栏的 content-desc,真机实测 `[381,166][765,223]` TextView 携带。
|
||||
* 只用于证明"当前确实在带搜索栏的首页",不作为点击目标。
|
||||
*/
|
||||
const val SEARCH_BAR_CONTENT_DESCRIPTION = "搜索"
|
||||
|
||||
fun classify(
|
||||
packageName: String?,
|
||||
visibleTexts: Collection<String>,
|
||||
sortControlCount: Int,
|
||||
hasClickableCameraEntry: Boolean,
|
||||
): ImageSearchPageKind {
|
||||
if (packageName != PDD_PACKAGE) return ImageSearchPageKind.NON_PDD
|
||||
if (PinduoduoImageSearchCriteria.isRetryDialog(visibleTexts)) return ImageSearchPageKind.RETRY_DIALOG
|
||||
if (PinduoduoImageSearchCriteria.isImageSearchResultsPage(visibleTexts, sortControlCount)) {
|
||||
return ImageSearchPageKind.IMAGE_SEARCH_RESULTS
|
||||
}
|
||||
if (PinduoduoImageSearchCriteria.isImageSearchEntryPage(visibleTexts)) return ImageSearchPageKind.IMAGE_SEARCH_ENTRY
|
||||
if (visibleTexts.any { text -> ORDER_CONFIRM_MARKERS.any(text::contains) }) return ImageSearchPageKind.ORDER_CONFIRM
|
||||
// `[必须]` HOME 只能由**可点**的相机入口证明,不能只看文案。真机核对
|
||||
// (2026-09-15)发现个人中心 tab 的根节点同样带 content-desc='拍照搜索',
|
||||
// 覆盖全屏且 clickable=false,而 visibleTexts() 会把 contentDescription
|
||||
// 当文本收集。只看文案会把个人中心判成首页,随后几何兜底就会点到该页
|
||||
// 顶部最靠右的可点节点——实测那是「设置」按钮。
|
||||
if (hasClickableCameraEntry) return ImageSearchPageKind.HOME
|
||||
return ImageSearchPageKind.OTHER_PDD
|
||||
}
|
||||
|
||||
fun nextAction(kind: ImageSearchPageKind): ImageSearchRecoveryAction = when (kind) {
|
||||
ImageSearchPageKind.IMAGE_SEARCH_ENTRY -> ImageSearchRecoveryAction.NONE
|
||||
ImageSearchPageKind.HOME -> ImageSearchRecoveryAction.CLICK_CAMERA
|
||||
ImageSearchPageKind.NON_PDD -> ImageSearchRecoveryAction.LAUNCH_PDD
|
||||
ImageSearchPageKind.RETRY_DIALOG -> ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG
|
||||
ImageSearchPageKind.IMAGE_SEARCH_RESULTS,
|
||||
ImageSearchPageKind.ORDER_CONFIRM,
|
||||
ImageSearchPageKind.OTHER_PDD,
|
||||
-> ImageSearchRecoveryAction.BACK
|
||||
}
|
||||
|
||||
fun exceedsRecoveryBudget(actionCount: Int): Boolean = actionCount >= MAX_RECOVERY_ACTIONS
|
||||
|
||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
|
||||
// 只读识别信号:用于判断当前是否停在订单确认形态的页面,从而选择"按返回"这一条归位路径。
|
||||
// 这里的任何文案都不会被配置成点击目标。
|
||||
private val ORDER_CONFIRM_MARKERS = listOf(
|
||||
"提交订单", "确认购买", "现在买,仅",
|
||||
"待付款", "待支付", "去支付", "立即支付", "确认支付",
|
||||
)
|
||||
}
|
||||
|
||||
/** 图搜结果页里的一张候选卡片,供纯函数挑选。 */
|
||||
data class ImageSearchResultCard(
|
||||
val top: Int,
|
||||
val left: Int,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val texts: List<String>,
|
||||
/** 卡片内图片是否已加载完成;不作为过滤条件,仅用于诊断/测试,容忍未加载完成的卡片。 */
|
||||
val imageLoaded: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* 结果页候选卡片挑选:取第一个(先按 top 再按 left),跳过带"广告"文案的卡片。
|
||||
* 不检查 [ImageSearchResultCard.imageLoaded]——参考项目 T-271 曾因为"图片未加载完时卡片读不出来"
|
||||
* 在真机失败过,这里刻意不把加载状态当作过滤条件。
|
||||
*/
|
||||
object ImageSearchCandidatePolicy {
|
||||
private const val AD_MARKER = "广告"
|
||||
|
||||
/** 节点是否完全落在卡片矩形内。 */
|
||||
fun inside(card: ImageSearchResultCard, bounds: NodeBounds): Boolean =
|
||||
bounds.left >= card.left && bounds.top >= card.top &&
|
||||
bounds.right <= card.left + card.width && bounds.bottom <= card.top + card.height
|
||||
|
||||
/**
|
||||
* 卡片内最靠上的有文案节点,作为点击目标。
|
||||
*
|
||||
* `[必须]` 点击目标不能是卡片容器本身。快照里 SnapshotNode.label 只取节点**自身**的
|
||||
* text/contentDescription,而 clickFreshDetailed 重新定位时用 preferredOrDescendantLabel()
|
||||
* ——会往子孙里挖。候选卡片是自身无文案的 FrameLayout,两者算出的 label 一个是空串、
|
||||
* 一个是标题文字,永远对不上,重找直接返回 TARGET_NOT_FOUND。改为瞄准有文案的子节点,
|
||||
* 由 clickFreshDetailed 既有的"向上走到可点祖先"逻辑落到卡片容器上。
|
||||
*/
|
||||
fun clickTargetInside(card: ImageSearchResultCard, nodes: List<SnapshotNode>): SnapshotNode? =
|
||||
nodes.filter { it.label.isNotEmpty() && inside(card, it.bounds) }.minByOrNull { it.bounds.top }
|
||||
|
||||
fun firstOpenableCandidate(cards: List<ImageSearchResultCard>): ImageSearchResultCard? =
|
||||
cards
|
||||
.filter { card -> card.texts.none { it.contains(AD_MARKER) } }
|
||||
.sortedWith(compareBy({ it.top }, { it.left }))
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
/** 相册里的一条媒体记录(图片或视频),仅取判定所需字段。 */
|
||||
data class ImageSearchMediaEntry(val id: Long, val dateAdded: Long)
|
||||
|
||||
/**
|
||||
* 坑②:现有 [PinduoduoImageSearchAssetStore.isMostRecent] 只比较 jpeg,若相册里有更新的视频,
|
||||
* 网格第一格就不是我们写入的图。这里把"最新一项"的判定扩大到全部媒体(图片 + 视频),
|
||||
* 并入 [PinduoduoImageSearchAssetStore] 时以 MediaStore.Files 同时查询两种类型。
|
||||
* 找不到自己、或者存在按 (dateAdded, id) 排序更靠前的记录时,一律返回 false(明确失败,不往后找)。
|
||||
*/
|
||||
object ImageSearchMediaRecencyPolicy {
|
||||
fun isMostRecent(entries: List<ImageSearchMediaEntry>, ownId: Long): Boolean {
|
||||
if (entries.isEmpty()) return false
|
||||
val latest = entries.maxWithOrNull(compareBy({ it.dateAdded }, { it.id })) ?: return false
|
||||
return latest.id == ownId
|
||||
}
|
||||
}
|
||||
|
||||
data class ImageSearchAutomationResult(val successful: Boolean, val code: String, val message: String)
|
||||
|
||||
/**
|
||||
* 图搜状态机执行器:驱动 [PddCollectorDriver] 完成"归位到图搜入口 -> 选相册第一张 -> 等结果页 ->
|
||||
* 打开第一个候选"。goods_id 识别与商品采集接回现有的 [CurrentPageIdentityRunner] +
|
||||
* [PddProductDetailCollector] 链路(在 AgentForegroundService 里完成),本类只负责把
|
||||
* 拼多多带到"候选商品详情页已打开"这一步。
|
||||
*
|
||||
* `[未验证]` 全部真机点击序列均未在真机执行,只能保证纯判定逻辑(上面三个 object)经过单测覆盖。
|
||||
*/
|
||||
class PinduoduoImageSearchAutomation(
|
||||
private val driver: PddCollectorDriver,
|
||||
private val currentPackage: () -> String?,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
) {
|
||||
/**
|
||||
* 归位到图搜入口页并在同一次迭代内选中相册第一张。
|
||||
*
|
||||
* `[必须]` 归位与选图必须合成一个循环,不能拆成 restoreToEntry() + selectFirstRecentImage()
|
||||
* 两次调用。2026-09-15 真机实测:进入图搜页后拼多多的摄像头开始实时识别,识别不到东西
|
||||
* 约 4 秒后自己弹出"再试一次"弹窗,把相册网格整个遮住;而该弹窗是独立 window,
|
||||
* rootInActiveWindow 只能看到弹窗的 13 个节点,底下的图搜页完全不可见。
|
||||
*
|
||||
* 拆成两次调用时,中间要多一次完整的无障碍树抓取(图搜页节点很多,开销不小),
|
||||
* 等第二次抓取回来时弹窗往往已经弹出,于是找不到"最近项目"锚点并报
|
||||
* IMAGE_SEARCH_ENTRY_NOT_FOUND——线上任务 262 连续四次都是这样,每次约 3 秒。
|
||||
* 手动实测在 1.8 秒内点中第一格可以干净进入结果页,所以余量存在,只是不能浪费。
|
||||
*
|
||||
* 结构参考同作者的 cmroubao_old(android-buyer PinduoduoImageSearchAutomation):
|
||||
* 检测到图搜页的那一刻就在当前快照上选图,并且每个动作之后等待预期页面出现,
|
||||
* 而不是盲目重判——页面加载中时判据一个都不命中,会被误判成其它页面而按下返回键。
|
||||
*/
|
||||
fun enterAndSelectImage(
|
||||
launchPdd: () -> PddLaunchResult,
|
||||
resetPddToHome: () -> PddLaunchResult = launchPdd,
|
||||
): ImageSearchAutomationResult {
|
||||
var actions = 0
|
||||
var dialogRecoveries = 0
|
||||
var stalledBacks = 0
|
||||
while (true) {
|
||||
val snapshot = driver.capture()
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
val texts = visible.map { it.label }
|
||||
PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, texts)?.let {
|
||||
return ImageSearchAutomationResult(false, it.code, it.message)
|
||||
}
|
||||
val sortControlCount = visible.count { it.label in SORT_CONTROL_LABELS }
|
||||
val cameraEntry = uniqueCameraEntry(visible)
|
||||
val kind = ImageSearchRecoveryPolicy.classify(
|
||||
currentPackage(), texts, sortControlCount, hasClickableCameraEntry = cameraEntry != null,
|
||||
)
|
||||
when (ImageSearchRecoveryPolicy.nextAction(kind)) {
|
||||
ImageSearchRecoveryAction.NONE -> {
|
||||
// 已在图搜入口页:就用刚抓到的这份快照选图,不再返回调用方、不再重新抓树。
|
||||
if (selectFirstRecentImageFrom(visible)) {
|
||||
return ImageSearchAutomationResult(true, "OK", "已选中相册第一张")
|
||||
}
|
||||
// 选不中通常是网格还没渲染出来,等一轮重来;弹窗弹出时会在下一轮被识别并关闭。
|
||||
}
|
||||
ImageSearchRecoveryAction.CLICK_CAMERA -> {
|
||||
val target = cameraEntry
|
||||
?: return ImageSearchAutomationResult(false, "IMAGE_SEARCH_ENTRY_NOT_FOUND", "找不到唯一可点的拍照搜索入口")
|
||||
driver.clickFreshDetailed(target)
|
||||
// `[必须]` 点完入口后等页面稳定再决策。图搜页加载中时判据一个都不命中,
|
||||
// 会被判成 OTHER_PDD 并按下返回键,等于自己把刚打开的图搜页退掉。
|
||||
val settled = awaitPage(ENTRY_SETTLE_TIMEOUT_MILLIS) {
|
||||
it == ImageSearchPageKind.IMAGE_SEARCH_ENTRY || it == ImageSearchPageKind.RETRY_DIALOG
|
||||
}
|
||||
if (settled != null && selectFirstRecentImageFrom(settled)) {
|
||||
return ImageSearchAutomationResult(true, "OK", "已选中相册第一张")
|
||||
}
|
||||
}
|
||||
ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG -> {
|
||||
dialogRecoveries++
|
||||
if (dialogRecoveries > ImageSearchRecoveryPolicy.MAX_RETRY_DIALOG_RECOVERIES) {
|
||||
return ImageSearchAutomationResult(
|
||||
false, "IMAGE_SEARCH_CAMERA_BLOCKED",
|
||||
"拼多多摄像头识别反复失败,图搜页始终被重试弹窗占据",
|
||||
)
|
||||
}
|
||||
if (!dismissRetryDialog(visible)) {
|
||||
return ImageSearchAutomationResult(
|
||||
false, "IMAGE_SEARCH_CAMERA_BLOCKED",
|
||||
"无法关闭重试弹窗,请人工处理后重试",
|
||||
)
|
||||
}
|
||||
}
|
||||
ImageSearchRecoveryAction.BACK -> {
|
||||
driver.back()
|
||||
// `[必须]` 按完返回要等页面真的换掉再判下一步。固定 300ms 盲等不够
|
||||
// PDD 完成一次切换,抓到的是动画中间态:判据一个都不命中,被归为
|
||||
// OTHER_PDD,于是再按一次——50 次预算 15 秒空转耗尽,App 可能一次
|
||||
// 完整返回都没做完(#292 真机任务 156)。CLICK_CAMERA 分支早就这么
|
||||
// 等了,这里是同一个坑漏掉的另一半。
|
||||
val moved = awaitPage(BACK_SETTLE_TIMEOUT_MILLIS) { it != kind } != null
|
||||
stalledBacks = if (moved) 0 else stalledBacks + 1
|
||||
if (stalledBacks >= MAX_STALLED_BACKS) {
|
||||
// 返回键推不动就别再按了:把 PDD 的返回栈清到根活动,直接回首页。
|
||||
stalledBacks = 0
|
||||
when (resetPddToHome()) {
|
||||
PddLaunchResult.INTENT_MISSING -> return ImageSearchAutomationResult(false, "PDD_APP_UNAVAILABLE", "没有找到拼多多应用,请确认已安装")
|
||||
PddLaunchResult.FAILED -> return ImageSearchAutomationResult(false, "PDD_FOREGROUND_LAUNCH_FAILED", "无法打开拼多多,请手动打开商品页后重试")
|
||||
PddLaunchResult.STARTED -> awaitPage(HOME_SETTLE_TIMEOUT_MILLIS) { it == ImageSearchPageKind.HOME }
|
||||
}
|
||||
}
|
||||
}
|
||||
ImageSearchRecoveryAction.LAUNCH_PDD -> {
|
||||
when (launchPdd()) {
|
||||
PddLaunchResult.INTENT_MISSING -> return ImageSearchAutomationResult(false, "PDD_APP_UNAVAILABLE", "没有找到拼多多应用,请确认已安装")
|
||||
PddLaunchResult.FAILED -> return ImageSearchAutomationResult(false, "PDD_FOREGROUND_LAUNCH_FAILED", "无法打开拼多多,请手动打开商品页后重试")
|
||||
PddLaunchResult.STARTED -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
actions++
|
||||
if (ImageSearchRecoveryPolicy.exceedsRecoveryBudget(actions)) {
|
||||
return ImageSearchAutomationResult(false, "IMAGE_SEARCH_ENTRY_NOT_FOUND", "归位到图搜入口并选中参考图失败,请人工检查")
|
||||
}
|
||||
pause(RECOVERY_STEP_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询直到页面稳定为 [accept] 接受的形态,命中时返回**那一次抓取**的可见节点,
|
||||
* 供调用方直接使用,避免为了拿到同一个页面再抓一次树。超时返回 null。
|
||||
*/
|
||||
private fun awaitPage(
|
||||
timeoutMillis: Long,
|
||||
accept: (ImageSearchPageKind) -> Boolean,
|
||||
): List<SnapshotNode>? {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() <= deadline) {
|
||||
val snapshot = driver.capture()
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
val texts = visible.map { it.label }
|
||||
val sortControlCount = visible.count { it.label in SORT_CONTROL_LABELS }
|
||||
val kind = ImageSearchRecoveryPolicy.classify(
|
||||
currentPackage(), texts, sortControlCount,
|
||||
hasClickableCameraEntry = uniqueCameraEntry(visible) != null,
|
||||
)
|
||||
if (accept(kind)) return visible
|
||||
pause(POLL_INTERVAL_MILLIS)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 点"取消"关闭"再试一次"弹窗。
|
||||
*
|
||||
* `[必须]` 不用 BACK:真机实测 BACK 对该弹窗完全无效。也不点"再试一次"——那会
|
||||
* 重新启动摄像头识别,几秒后再弹一次,形成死循环。
|
||||
*/
|
||||
private fun dismissRetryDialog(visible: List<SnapshotNode>): Boolean {
|
||||
val cancel = visible.firstOrNull {
|
||||
it.enabled && it.clickable && it.label == PinduoduoImageSearchCriteria.RETRY_DIALOG_CANCEL_TEXT
|
||||
} ?: return false
|
||||
return driver.clickFreshDetailed(cancel).result == FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
/** 只保留唯一同时满足 content-desc 和 clickable 的节点;否则按几何兜底(顶部区域最靠右的可点节点)。 */
|
||||
private fun uniqueCameraEntry(visible: List<SnapshotNode>): SnapshotNode? {
|
||||
val byText = visible.filter {
|
||||
it.enabled && it.clickable && it.contentDescription == PinduoduoImageSearchCriteria.CAMERA_ENTRY_CONTENT_DESCRIPTION
|
||||
}
|
||||
byText.singleOrNull()?.let { return it }
|
||||
if (byText.isNotEmpty()) return null
|
||||
// `[必须]` 几何兜底只在确实看得到顶部搜索栏时才允许。否则任何 PDD 页面都会
|
||||
// 交出"顶部最靠右的可点节点",在个人中心那是「设置」。实测首页搜索栏含
|
||||
// content-desc='搜索' 的节点,个人中心没有。
|
||||
val hasSearchBar = visible.any {
|
||||
it.visible && it.bounds.top <= PinduoduoImageSearchCriteria.CAMERA_ENTRY_FALLBACK_MAX_TOP_PX &&
|
||||
it.contentDescription?.trim() == ImageSearchRecoveryPolicy.SEARCH_BAR_CONTENT_DESCRIPTION
|
||||
}
|
||||
if (!hasSearchBar) return null
|
||||
val fallback = visible.filter {
|
||||
it.enabled && it.clickable && it.bounds.top <= PinduoduoImageSearchCriteria.CAMERA_ENTRY_FALLBACK_MAX_TOP_PX
|
||||
}
|
||||
return fallback.maxByOrNull { it.bounds.right }
|
||||
}
|
||||
|
||||
/**
|
||||
* 在**已经抓好**的快照上定位"最近项目"锚点下方的网格并点击第一格。
|
||||
*
|
||||
* `[必须]` 接收 visible 而不是自己 capture:见 [enterAndSelectImage] 的说明,
|
||||
* 多一次抓取就可能错过 4 秒窗口。
|
||||
*/
|
||||
fun selectFirstRecentImageFrom(visible: List<SnapshotNode>): Boolean {
|
||||
val anchor = visible.firstOrNull { it.label.contains(PinduoduoImageSearchCriteria.RECENT_ITEMS_LABEL) } ?: return false
|
||||
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return false
|
||||
val cells = visible
|
||||
.filter { it.clickable && it.bounds.top >= anchor.bounds.bottom }
|
||||
.map { PinduoduoImageSearchCriteria.GridCandidate(it.bounds.top, it.bounds.left, it.bounds.width, it.bounds.height) }
|
||||
val target = PinduoduoImageSearchCriteria.firstRecentImageGridCell(cells, screenWidth) ?: return false
|
||||
val node = visible.firstOrNull {
|
||||
it.clickable && it.bounds.top == target.top && it.bounds.left == target.left &&
|
||||
it.bounds.width == target.width && it.bounds.height == target.height
|
||||
} ?: return false
|
||||
return driver.clickFreshDetailed(node).result == FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询直到进入结果页。
|
||||
*
|
||||
* `[必须]` 出现"再试一次"弹窗时**不点"再试一次"**:那会重启摄像头识别、几秒后再弹一次。
|
||||
* 这里直接判失败交回调用方,由 restoreToEntry 走关闭并重新进入的路径(有独立次数上限)。
|
||||
*/
|
||||
fun waitForResultsPage(timeoutMillis: Long = DEFAULT_RESULTS_TIMEOUT_MILLIS): Boolean {
|
||||
// 失败由调用方映射为 IMAGE_SEARCH_RESULTS_NOT_READY,与"结果页出来了但候选点不动"
|
||||
// 区分开——两者此前共用 IMAGE_SEARCH_NO_CANDIDATES,定位问题时完全分不清。
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() <= deadline) {
|
||||
val snapshot = driver.capture()
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
val texts = visible.map { it.label }
|
||||
val sortControlCount = visible.count { it.label in SORT_CONTROL_LABELS }
|
||||
if (PinduoduoImageSearchCriteria.isImageSearchResultsPage(texts, sortControlCount)) return true
|
||||
if (PinduoduoImageSearchCriteria.isRetryDialog(texts)) return false
|
||||
pause(RECOVERY_STEP_MILLIS)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 打开结果页第一个非广告候选卡片;容忍卡片内图片尚未加载完成。 */
|
||||
fun openFirstCandidate(): ImageSearchAutomationResult {
|
||||
val snapshot = driver.capture()
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
val cardContainers = visible.filter { node ->
|
||||
node.clickable &&
|
||||
node.bounds.width in CARD_WIDTH_RANGE &&
|
||||
node.bounds.height in CARD_HEIGHT_RANGE
|
||||
}
|
||||
if (cardContainers.isEmpty()) {
|
||||
return ImageSearchAutomationResult(
|
||||
false, "IMAGE_SEARCH_NO_CANDIDATES", "结果页没有可识别的商品卡片",
|
||||
)
|
||||
}
|
||||
// `[必须]` 卡片内文案按**几何包含**判定,不用 path 前缀。path 的父子语义从未核对过,
|
||||
// 而 bounds 包含在 2026-09-15 的真机 dump 上直接验证过:四张 535x764 卡片各自的
|
||||
// 标题、价格、店铺文案都能正确归位。
|
||||
val cards = cardContainers.map { container ->
|
||||
ImageSearchResultCard(
|
||||
top = container.bounds.top,
|
||||
left = container.bounds.left,
|
||||
width = container.bounds.width,
|
||||
height = container.bounds.height,
|
||||
texts = visible.filter {
|
||||
it.label.isNotEmpty() && ImageSearchCandidatePolicy.inside(
|
||||
ImageSearchResultCard(container.bounds.top, container.bounds.left, container.bounds.width, container.bounds.height, emptyList()),
|
||||
it.bounds,
|
||||
)
|
||||
}.map { it.label },
|
||||
)
|
||||
}
|
||||
val chosen = ImageSearchCandidatePolicy.firstOpenableCandidate(cards)
|
||||
?: return ImageSearchAutomationResult(
|
||||
false, "IMAGE_SEARCH_NO_CANDIDATES",
|
||||
"结果页 ${cards.size} 张卡片全部是广告,没有可采集的候选",
|
||||
)
|
||||
val container = cardContainers.firstOrNull {
|
||||
it.bounds.top == chosen.top && it.bounds.left == chosen.left &&
|
||||
it.bounds.width == chosen.width && it.bounds.height == chosen.height
|
||||
} ?: return ImageSearchAutomationResult(
|
||||
false, "IMAGE_SEARCH_CANDIDATE_CLICK_FAILED", "选中的候选卡片在点击前消失",
|
||||
)
|
||||
|
||||
// `[必须]` 不能直接点卡片容器。快照里 SnapshotNode.label 只取节点**自身**的
|
||||
// text/contentDescription,而 clickFreshDetailed 重新定位时用的是
|
||||
// preferredOrDescendantLabel()——会往子孙里挖。候选卡片是自身无文案的
|
||||
// FrameLayout,两者算出的 label 一个是空串、一个是标题文字,永远对不上,
|
||||
// 重找直接返回 TARGET_NOT_FOUND(2026-09-15 真机:任务 262 卡在这里)。
|
||||
// 改为瞄准卡片内有文案的子节点,由 clickFreshDetailed 既有的"向上走到可点祖先"
|
||||
// 逻辑落到卡片容器上。
|
||||
val labelled = ImageSearchCandidatePolicy.clickTargetInside(chosen, visible)
|
||||
?: return ImageSearchAutomationResult(
|
||||
false, "IMAGE_SEARCH_CANDIDATE_CLICK_FAILED", "候选卡片内没有可定位的文案节点",
|
||||
)
|
||||
val outcome = driver.clickFreshDetailed(labelled)
|
||||
if (outcome.result != FreshActionResult.SUCCESS) {
|
||||
return ImageSearchAutomationResult(
|
||||
false, "IMAGE_SEARCH_CANDIDATE_CLICK_FAILED",
|
||||
"候选卡片点击失败[result=${outcome.result};reason=${outcome.reason}]",
|
||||
)
|
||||
}
|
||||
return ImageSearchAutomationResult(true, "OK", "已打开第一个候选")
|
||||
}
|
||||
|
||||
|
||||
private fun SnapshotNode.width6xHeight(): Boolean = bounds.width > 0 && bounds.height > 0
|
||||
|
||||
private companion object {
|
||||
const val RECOVERY_STEP_MILLIS = 300L
|
||||
|
||||
/** 动作之间的轮询间隔,取自 cmroubao_old 的实测值。 */
|
||||
const val POLL_INTERVAL_MILLIS = 200L
|
||||
|
||||
/**
|
||||
* 点击图搜入口后等待页面稳定的上限。
|
||||
*
|
||||
* `[必须]` 必须明显短于摄像头自动弹出重试弹窗的约 4 秒,否则等到的就是弹窗而不是图搜页。
|
||||
*/
|
||||
const val ENTRY_SETTLE_TIMEOUT_MILLIS = 2_500L
|
||||
|
||||
/** 按下返回后等待页面形态变化的上限。 */
|
||||
const val BACK_SETTLE_TIMEOUT_MILLIS = 1_500L
|
||||
|
||||
/** 连续多少次返回推不动页面后,改用清栈重启回首页。 */
|
||||
const val MAX_STALLED_BACKS = 3
|
||||
|
||||
/** 清栈回首页后等待首页出现的上限。 */
|
||||
const val HOME_SETTLE_TIMEOUT_MILLIS = 5_000L
|
||||
const val DEFAULT_RESULTS_TIMEOUT_MILLIS = 15_000L
|
||||
val SORT_CONTROL_LABELS = setOf("综合", "销量", "价格", "品牌")
|
||||
val CARD_WIDTH_RANGE = 400..700
|
||||
val CARD_HEIGHT_RANGE = 600..950
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
/**
|
||||
* PDD 图搜相关页面判据的纯函数实现(#277)。
|
||||
*
|
||||
* 常量来源:移植自同作者另一个项目(本机 D:\chengma\cmroubao_old),并已于
|
||||
* 2026-09-15 在真机(1080x2354,PDD HomeActivity / NewPageActivity)dump 无障碍
|
||||
* 树逐条核对,三处判据全部命中:
|
||||
*
|
||||
* 1. 首页搜索框右侧 `[941,161][1080,228] ImageView clickable=true`
|
||||
* content-desc 正是 [CAMERA_ENTRY_CONTENT_DESCRIPTION]。
|
||||
* `[必须]` 必须同时要求 clickable:个人中心 tab 的**根节点**也带着同一个
|
||||
* content-desc 且覆盖全屏、clickable=false,只按文案匹配会点到整屏。
|
||||
* 实测首页该文案共 2 个节点、仅 1 个可点,"唯一可点"过滤是必需的。
|
||||
* 2. 图搜页 [isImageSearchEntryPage] 的四段文案齐备;`最近项目` 锚点
|
||||
* `[0,1892][210,1898]`,其下网格每格 267px,267*4=1068,屏宽 1080,
|
||||
* 落在 ±[GRID_WIDTH_TOLERANCE_PX] 容差内。
|
||||
* 3. 结果页 `搜图片同款` 出现,排序控件为 综合/销量/价格/品牌 共 4 个(>=3)。
|
||||
*
|
||||
* 已知与移植来源的差异,以及尚未验证的部分,见下方各常量注释。
|
||||
*/
|
||||
object PinduoduoImageSearchCriteria {
|
||||
|
||||
/** 首页/搜索结果页上,图搜入口按钮的 contentDescription。要求唯一匹配后才可点击。 */
|
||||
const val CAMERA_ENTRY_CONTENT_DESCRIPTION = "拍照搜索"
|
||||
|
||||
/**
|
||||
* 几何兜底(用户 2026-09-15 提供):若按 content-desc 找不到入口,首页顶部
|
||||
* 区域最靠右的可点节点即是相机图标。实测 `[941,161][1080,228]` 紧贴右边缘。
|
||||
* 仅在文案匹配失败时使用,且必须限制在顶部搜索栏所在的纵向范围内。
|
||||
*/
|
||||
const val CAMERA_ENTRY_FALLBACK_MAX_TOP_PX = 320
|
||||
|
||||
/**
|
||||
* "再试一次"弹窗文案。
|
||||
*
|
||||
* `[已验证]` 2026-09-15 真机复现,三个常量全部命中。进入图搜页后摄像头会
|
||||
* 实时识别,识别不到东西约 4 秒后拼多多**自己**弹出该弹窗(+3s 无、+5s 有),
|
||||
* 不需要我们点任何东西。它会遮住相册网格。
|
||||
*
|
||||
* `[必须]` 按 BACK 对该弹窗**完全无效**(前后两次 dump 逐字节相同);点「取消」
|
||||
* 可以关闭,但会退出图搜、回到拼多多首页。实测在 1.8 秒内点中相册第一格
|
||||
* 可以赶在弹窗之前干净进入结果页。
|
||||
*/
|
||||
const val RETRY_DIALOG_CANCEL_TEXT = "取消"
|
||||
const val RETRY_DIALOG_CONFIRM_TEXT = "再试一次"
|
||||
const val RETRY_DIALOG_HINT_TEXT = "请对准商品或码,保持手机稳定"
|
||||
|
||||
/**
|
||||
* 相册网格锚点。
|
||||
*
|
||||
* `[注意]` 真机实测该网格同时包含**视频**(观察到带时长文案 03:02 的格子)。
|
||||
* 资产准备侧的"自己是否为相册最新一张"只比较 jpeg,若有更新的视频排在前面,
|
||||
* 第一格就不是我们写入的图。接线时必须复核这一点,必要时改为按 uri 定位。
|
||||
*/
|
||||
const val RECENT_ITEMS_LABEL = "最近项目"
|
||||
|
||||
private const val GRID_COLUMNS = 4
|
||||
private const val GRID_WIDTH_TOLERANCE_PX = 24
|
||||
|
||||
/** 图搜首页(选择"最近项目"/拍照)判据:需同时出现相册、最近搜索、历史浏览等文案,且有拍照引导。 */
|
||||
fun isImageSearchEntryPage(texts: Collection<String>): Boolean {
|
||||
val hasAlbum = texts.any { it.contains("我的相册") }
|
||||
val hasRecentSearch = texts.any { it.contains("最近搜索") }
|
||||
val hasHistory = texts.any { it.contains("历史浏览") }
|
||||
val hasCameraHint = texts.any {
|
||||
// 真机实测取景提示是「对准商品/条形码/二维码,自动识别」,移植来源的
|
||||
// 「即可进行自动识别」不命中;靠「点击拍照」兜住。三者保留为或关系,
|
||||
// 以覆盖未授予相机权限等其它形态。
|
||||
it.contains("点击拍照") || it.contains("开启相机权限") || it.contains("即可进行自动识别")
|
||||
}
|
||||
return hasAlbum && hasRecentSearch && hasHistory && hasCameraHint
|
||||
}
|
||||
|
||||
/** 图搜结果页判据:出现"搜图片同款"标题,且排序/筛选控件数量达到阈值。 */
|
||||
fun isImageSearchResultsPage(texts: Collection<String>, sortControlCount: Int): Boolean =
|
||||
texts.any { it.contains("搜图片同款") } && sortControlCount >= 3
|
||||
|
||||
/** "再试一次"弹窗判据。 */
|
||||
fun isRetryDialog(texts: Collection<String>): Boolean {
|
||||
val hasHint = texts.any { it.contains(RETRY_DIALOG_HINT_TEXT) }
|
||||
val hasCancel = texts.any { it == RETRY_DIALOG_CANCEL_TEXT }
|
||||
val hasRetry = texts.any { it == RETRY_DIALOG_CONFIRM_TEXT }
|
||||
return hasHint && hasCancel && hasRetry
|
||||
}
|
||||
|
||||
data class GridCandidate(val top: Int, val left: Int, val width: Int, val height: Int)
|
||||
|
||||
/**
|
||||
* 在"最近项目"标签下方的候选矩形里,挑出 4 列等宽网格(宽度 * 4 约等于屏宽,容差
|
||||
* [GRID_WIDTH_TOLERANCE_PX]px;高度 >= 宽度/2)里最靠左上的一个(先按 top 再按 left 排序)。
|
||||
* 找不到符合条件的候选时返回 null。
|
||||
*/
|
||||
fun firstRecentImageGridCell(candidates: List<GridCandidate>, screenWidth: Int): GridCandidate? {
|
||||
val fourColumns = candidates.filter { cell ->
|
||||
cell.width > 0 &&
|
||||
cell.height >= cell.width / 2 &&
|
||||
kotlin.math.abs(cell.width * GRID_COLUMNS - screenWidth) <= GRID_WIDTH_TOLERANCE_PX
|
||||
}
|
||||
return fourColumns.sortedWith(compareBy({ it.top }, { it.left })).firstOrNull()
|
||||
}
|
||||
}
|
||||
+154
-20
@@ -19,8 +19,16 @@ data class FinalConfirmationEvidence(
|
||||
val activityName: String,
|
||||
)
|
||||
|
||||
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String)
|
||||
data class PurchaseOrderReadFailure(val code: String, val message: String)
|
||||
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String, val pddOrderAmountCent: Long? = null)
|
||||
data class PurchaseOrderReadFailure(
|
||||
val code: String,
|
||||
val message: String,
|
||||
// `[必须]` 与是否读到订单号无关:只要采样期间出现过待付款/支付相关文案,
|
||||
// 就说明大概率已经点通了下单、PDD 侧可能已经建了订单,只是没能读全证据
|
||||
// (比如返回窗口太短、错过了订单详情页)。给 order_result_unknown 分优先级用,
|
||||
// 不改变判定结果本身(#302)。
|
||||
val paymentPageObserved: Boolean = false,
|
||||
)
|
||||
|
||||
class PurchaseLiveException(val code: String, message: String, val actualUnitPriceCent: Long? = null) : IllegalStateException(message)
|
||||
|
||||
@@ -32,6 +40,8 @@ class PurchaseLiveException(val code: String, message: String, val actualUnitPri
|
||||
class PurchaseLiveAutomation(
|
||||
private val driver: PurchaseUiDriver,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
/** Boolean/count-only diagnostics; never receives address text. */
|
||||
private val panelDiagnostic: (String) -> Unit = {},
|
||||
) {
|
||||
private var submitAttempted = false
|
||||
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
|
||||
@@ -340,8 +350,9 @@ class PurchaseLiveAutomation(
|
||||
/** Reads the order flow with bounded chooser/payment Back actions and one verified PDD foreground request. */
|
||||
fun readOrderResult(): PurchaseOrderEvidence? {
|
||||
lastOrderReadFailure = null
|
||||
var paymentPageObserved = false
|
||||
fun unknown(code: String, message: String): PurchaseOrderEvidence? {
|
||||
lastOrderReadFailure = PurchaseOrderReadFailure(code, message)
|
||||
lastOrderReadFailure = PurchaseOrderReadFailure(code, message, paymentPageObserved)
|
||||
return null
|
||||
}
|
||||
val labels = linkedSetOf<String>()
|
||||
@@ -353,7 +364,17 @@ class PurchaseLiveAutomation(
|
||||
var orderDetailEntryOpened = false
|
||||
var wechatRestorePendingSamples = 0
|
||||
var consecutiveEmptySnapshots = 0
|
||||
repeat(ORDER_RESULT_MAX_SAMPLES) { index ->
|
||||
var unpaidStartSample: Int? = null
|
||||
var unpaidSwipes = 0
|
||||
repeat(ORDER_RESULT_MAX_SAMPLES + ORDER_RESULT_UNPAID_MAX_SAMPLES) { index ->
|
||||
// The first unpaid page receives its own bounded budget, even after a long handoff.
|
||||
// Never reset it on repeated labels or navigation back to payment.
|
||||
val unpaidStart = unpaidStartSample
|
||||
if ((unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES) ||
|
||||
(unpaidStart != null && index - unpaidStart >= ORDER_RESULT_UNPAID_MAX_SAMPLES)) {
|
||||
val failure = orderEvidenceFailure(labels)
|
||||
return unknown(failure.code, failure.message)
|
||||
}
|
||||
val snapshot = driver.capture()
|
||||
if (snapshot.packageName.isNullOrBlank()) {
|
||||
consecutiveEmptySnapshots++
|
||||
@@ -399,6 +420,7 @@ class PurchaseLiveAutomation(
|
||||
// it permits only bounded reading gestures below, never a payment click.
|
||||
val paymentVisible = isKnownPddPaymentActivity(snapshot) && !orderContextVisible && !unpaidContextVisible
|
||||
if (paymentVisible) {
|
||||
paymentPageObserved = true
|
||||
if (paymentBackAttempts == 0) {
|
||||
paymentBackAttempts++
|
||||
if (!driver.backPurchase()) {
|
||||
@@ -420,7 +442,16 @@ class PurchaseLiveAutomation(
|
||||
return@repeat
|
||||
}
|
||||
consecutivePaymentSamplesAfterBack = 0
|
||||
if (unpaidContextVisible) {
|
||||
paymentPageObserved = true
|
||||
if (unpaidStartSample == null) unpaidStartSample = index
|
||||
}
|
||||
if (!orderContextVisible && !unpaidContextVisible) {
|
||||
if (unpaidStartSample != null) {
|
||||
// After reaching the unpaid page only observe; never click a newly exposed control.
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
return@repeat
|
||||
}
|
||||
val entries = orderDetailEntryTargets(snapshot)
|
||||
if (entries.size > 1) {
|
||||
return unknown("PURCHASE_ORDER_DETAIL_ENTRY_AMBIGUOUS", "订单详情入口不唯一,已停止只读核单")
|
||||
@@ -443,7 +474,13 @@ class PurchaseLiveAutomation(
|
||||
}
|
||||
currentLabels.forEach(labels::add)
|
||||
parseOrderEvidence(labels)?.let { return it }
|
||||
if (index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
|
||||
if (unpaidStartSample != null && unpaidSwipes < ORDER_RESULT_UNPAID_MAX_SWIPES) {
|
||||
unpaidSwipes++
|
||||
driver.swipePurchase(SwipeDirection.UP, 400)
|
||||
pause(ORDER_RESULT_UNPAID_SETTLE_MS)
|
||||
return@repeat
|
||||
}
|
||||
if (unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
|
||||
driver.swipePurchase(SwipeDirection.UP, 400)
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
@@ -452,6 +489,9 @@ class PurchaseLiveAutomation(
|
||||
return unknown(failure.code, failure.message)
|
||||
}
|
||||
|
||||
// orderEvidenceFailure 之外的路径(选择器卡住、微信恢复超时等)同样要带上
|
||||
// 已经采集到的 paymentPageObserved,不能让 unknown() 之外的 return 漏标。
|
||||
|
||||
private fun isKnownAndroidWechatChooser(snapshot: UiSnapshot, labels: Collection<String>): Boolean {
|
||||
val packageKnown = snapshot.packageName in ANDROID_CHOOSER_PACKAGES
|
||||
val activity = snapshot.activityName.orEmpty()
|
||||
@@ -485,7 +525,7 @@ class PurchaseLiveAutomation(
|
||||
val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" })
|
||||
if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单")
|
||||
click(save.single(), "保存地址")
|
||||
val savedEvidence = waitForStableAddressEditorExit()
|
||||
val savedEvidence = waitForStableAddressEditorExit(expected, suffix)
|
||||
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
|
||||
if (isPurchaseConfirmationPanel(savedEvidence)) {
|
||||
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||
@@ -502,11 +542,7 @@ class PurchaseLiveAutomation(
|
||||
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
|
||||
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
return screen.specPanelType in setOf(
|
||||
SpecPanelType.NORMAL_SCROLLABLE,
|
||||
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
|
||||
SpecPanelType.ORDER_CONFIRMATION,
|
||||
)
|
||||
return screen.specPanelType in PURCHASE_CONFIRMATION_PANEL_TYPES
|
||||
}
|
||||
|
||||
private fun restoreFinalEvidenceInCurrentPanel(
|
||||
@@ -560,14 +596,14 @@ class PurchaseLiveAutomation(
|
||||
private fun hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
|
||||
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).size == 1
|
||||
|
||||
private fun waitForStableAddressEditorExit(): UiSnapshot {
|
||||
private fun waitForStableAddressEditorExit(expected: String, suffix: String): UiSnapshot {
|
||||
var consecutiveExits = 0
|
||||
repeat(50) {
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
if (snapshot.packageName == PDD_PACKAGE && shippingAddressEditors(snapshot).isEmpty()) {
|
||||
consecutiveExits++
|
||||
if (consecutiveExits >= 2) return snapshot
|
||||
if (consecutiveExits >= 2) return waitForSettledPanelAfterAddressSave(snapshot, expected, suffix)
|
||||
} else {
|
||||
consecutiveExits = 0
|
||||
}
|
||||
@@ -576,6 +612,68 @@ class PurchaseLiveAutomation(
|
||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存超时,未创建订单")
|
||||
}
|
||||
|
||||
/**
|
||||
* #331: after the editor closes PDD may still be rebuilding the purchase
|
||||
* sheet. Accept immediately when the final evidence or a stronger panel type
|
||||
* is already present. A sheet recognized only through the required-evidence
|
||||
* fallback must also be structurally stable for two consecutive samples. A
|
||||
* stable page without any purchase-sheet marker (quantity input or payment
|
||||
* entry), e.g. the address list, keeps the existing caller path. A partially
|
||||
* loaded sheet is waited for up to [SpecPanelStabilityPolicy.WAIT_BOUND_MS]
|
||||
* without pressing Back and then fails explicitly.
|
||||
*/
|
||||
private fun waitForSettledPanelAfterAddressSave(initial: UiSnapshot, expected: String, suffix: String): UiSnapshot {
|
||||
var snapshot = initial
|
||||
var previousSignature: String? = null
|
||||
var waitedMs = 0L
|
||||
var samples = 0
|
||||
while (true) {
|
||||
samples++
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
val editorGone = snapshot.packageName == PDD_PACKAGE && shippingAddressEditors(snapshot).isEmpty()
|
||||
val signature = structureSignature(snapshot)
|
||||
val stable = editorGone && signature == previousSignature
|
||||
val outcome = when {
|
||||
editorGone && hasFinalSavedAddressEvidence(snapshot, expected, suffix) -> "final_evidence"
|
||||
editorGone && screen.specPanelType in STRONG_PURCHASE_PANEL_TYPES -> "recognized"
|
||||
stable && screen.specPanelType == SpecPanelType.REQUIRED_EVIDENCE -> "stable_required"
|
||||
stable && !screen.hasQuantityInput && !screen.hasPaymentEntry -> "non_panel"
|
||||
else -> null
|
||||
}
|
||||
if (outcome != null) {
|
||||
addressSaveDiagnostic(outcome, waitedMs, samples, screen)
|
||||
return snapshot
|
||||
}
|
||||
if (waitedMs >= SpecPanelStabilityPolicy.WAIT_BOUND_MS) {
|
||||
addressSaveDiagnostic("timeout", waitedMs, samples, screen)
|
||||
fail(
|
||||
"PURCHASE_ADDRESS_SAVE_TIMEOUT",
|
||||
"地址保存后规格面板未完整加载,未按返回键,未创建订单 [waitedMs=$waitedMs;samples=$samples;${screen.requiredEvidenceSummary()}]${screen.requiredEvidenceHintSuffix()}",
|
||||
)
|
||||
}
|
||||
previousSignature = signature
|
||||
pause(SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS)
|
||||
waitedMs += SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS
|
||||
snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addressSaveDiagnostic(outcome: String, waitedMs: Long, samples: Int, screen: ParsedPddScreen) {
|
||||
panelDiagnostic(
|
||||
"addressSaveWait;outcome=$outcome;waitedMs=$waitedMs;samples=$samples;backPressed=false;" +
|
||||
"type=${screen.specPanelType};${screen.requiredEvidenceSummary()}",
|
||||
)
|
||||
}
|
||||
|
||||
/** In-memory structure only (path, class, bounds, flags); no text is included. */
|
||||
private fun structureSignature(snapshot: UiSnapshot): String = snapshot.nodes
|
||||
.filter { it.visible }
|
||||
.joinToString("|") { node ->
|
||||
listOf(node.path, node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable, node.enabled)
|
||||
.joinToString(":")
|
||||
}
|
||||
|
||||
private fun normalizeAddressText(value: String): String = value.filterNot(Char::isWhitespace)
|
||||
|
||||
private fun hasExactTaskSuffix(value: String, suffix: String): Boolean =
|
||||
@@ -678,10 +776,25 @@ class PurchaseLiveAutomation(
|
||||
pause(500)
|
||||
}
|
||||
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
|
||||
snapshot,
|
||||
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
|
||||
)
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val byPath = snapshot.nodes.associateBy { it.path }
|
||||
fun hasArea(node: SnapshotNode) = node.bounds.width > 0 && node.bounds.height > 0
|
||||
// #331: recognition may treat the submit action as auxiliary, but the
|
||||
// final click target stays strict. A zero-size label or a zero-size
|
||||
// clickable container is never a click target.
|
||||
fun clickableAncestorHasArea(node: SnapshotNode): Boolean {
|
||||
var current: SnapshotNode? = node
|
||||
while (current != null && !current.clickable) current = current.parentPath?.let(byPath::get)
|
||||
return current != null && hasArea(current)
|
||||
}
|
||||
return uniqueClickable(
|
||||
snapshot,
|
||||
snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && hasArea(node) && clickableAncestorHasArea(node) &&
|
||||
FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean {
|
||||
if (snapshot.packageName != PDD_PACKAGE) return false
|
||||
@@ -714,7 +827,18 @@ class PurchaseLiveAutomation(
|
||||
runCatching { SimpleDateFormat(pattern, Locale.ROOT).apply { isLenient = false; timeZone = TimeZone.getTimeZone("Asia/Shanghai") }.parse(normalized) }.getOrNull()
|
||||
} ?: return null
|
||||
val output = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(parsed)
|
||||
return PurchaseOrderEvidence(orderNumbers.single(), output)
|
||||
return PurchaseOrderEvidence(orderNumbers.single(), output, parsePayableAmount(labels))
|
||||
}
|
||||
|
||||
private fun parsePayableAmount(labels: Collection<String>): Long? {
|
||||
val amounts = labels.flatMap { label ->
|
||||
PAYABLE_AMOUNT.findAll(label).map { match ->
|
||||
runCatching { match.groupValues[1].toBigDecimal().movePointRight(2).longValueExact() }.getOrNull()
|
||||
}.toList()
|
||||
}
|
||||
// Repeated parent/child labels are fine; conflicting/overflow amounts are omitted.
|
||||
if (amounts.any { it == null }) return null
|
||||
return amounts.distinct().singleOrNull()
|
||||
}
|
||||
|
||||
private fun orderEvidenceFailure(labels: Collection<String>): PurchaseOrderReadFailure {
|
||||
@@ -744,12 +868,19 @@ class PurchaseLiveAutomation(
|
||||
private companion object {
|
||||
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
const val WECHAT_PACKAGE = "com.tencent.mm"
|
||||
val MASKED_PHONE = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
|
||||
val MASKED_PHONE = PddScreenParser.MASKED_PHONE_PATTERN
|
||||
val STRONG_PURCHASE_PANEL_TYPES = setOf(
|
||||
SpecPanelType.NORMAL_SCROLLABLE,
|
||||
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
|
||||
SpecPanelType.ORDER_CONFIRMATION,
|
||||
)
|
||||
val PURCHASE_CONFIRMATION_PANEL_TYPES = STRONG_PURCHASE_PANEL_TYPES + SpecPanelType.REQUIRED_EVIDENCE
|
||||
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
|
||||
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
|
||||
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
|
||||
val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情")
|
||||
val ORDER_CONTEXT_MARKERS = listOf("订单编号", "订单号", "下单时间", "创建时间")
|
||||
val PAYABLE_AMOUNT = Regex("(?<![\\p{L}])应付[ \\t::,,]*[¥¥]?[ \\t]*(\\d+(?:\\.\\d{1,2})?)[ \\t]*元(?![0-9.])")
|
||||
val ORDER_NO = Regex("(?:订单编号|订单号)\\s*[::]?\\s*([A-Za-z0-9-]{6,64})")
|
||||
val ORDER_TIME = Regex("(?:下单时间|创建时间)\\s*[::]?\\s*(20[0-9]{2}[-/.年][0-9]{1,2}[-/.月][0-9]{1,2}日?\\s+[0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)")
|
||||
val ANDROID_CHOOSER_PACKAGES = setOf("android", "com.android.intentresolver")
|
||||
@@ -763,7 +894,10 @@ class PurchaseLiveAutomation(
|
||||
const val ORDER_RESULT_MAX_SAMPLES = 60
|
||||
const val ORDER_RESULT_MAX_EMPTY_SAMPLES = 15
|
||||
const val ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES = 15
|
||||
const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 3
|
||||
const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 25
|
||||
const val ORDER_RESULT_UNPAID_MAX_SAMPLES = 30
|
||||
const val ORDER_RESULT_UNPAID_MAX_SWIPES = 4
|
||||
const val ORDER_RESULT_UNPAID_SETTLE_MS = 500L
|
||||
const val ORDER_RESULT_SCROLL_SAMPLE_INTERVAL = 15
|
||||
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
|
||||
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
|
||||
|
||||
+65
-8
@@ -77,6 +77,9 @@ data class PurchaseExecutionOutcome(
|
||||
val pddOrderNo: String? = null,
|
||||
val orderSubmittedAt: String? = null,
|
||||
val actualUnitPriceCent: Long? = null,
|
||||
// 仅 order_result_unknown 有意义:见 PurchaseOrderReadFailure 的说明(#302)。
|
||||
val paymentPageObserved: Boolean = false,
|
||||
val pddOrderAmountCent: Long? = null,
|
||||
)
|
||||
|
||||
class PurchaseRehearsalExecutor(
|
||||
@@ -97,7 +100,7 @@ class PurchaseRehearsalExecutor(
|
||||
var addressProof: ShippingAddressProof? = null
|
||||
var irreversibleStarted = false
|
||||
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
|
||||
val live = PurchaseLiveAutomation(driver, pause)
|
||||
val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic)
|
||||
for (action in rule.actions) {
|
||||
// The immediate phase-two handoff can reuse the PDD page retained by
|
||||
// spec_probe. A later manual retry may start from Agent (or another
|
||||
@@ -140,6 +143,8 @@ class PurchaseRehearsalExecutor(
|
||||
live.submitOrderOnce()
|
||||
null
|
||||
} catch (error: PurchaseLiveException) {
|
||||
// paymentPageObserved 留默认 false:此处是 submitOrderOnce() 本身失败
|
||||
// (提交按钮点击不明确),readOrderResult 还没跑过,谈不上见没见过支付页。
|
||||
if (irreversibleStarted) PurchaseExecutionOutcome(
|
||||
"order_result_unknown",
|
||||
"PURCHASE_ORDER_RESULT_UNKNOWN",
|
||||
@@ -150,13 +155,14 @@ class PurchaseRehearsalExecutor(
|
||||
}
|
||||
PurchaseActionType.READ_ORDER_RESULT -> {
|
||||
if (!irreversibleStarted) failure("PURCHASE_RULE_INVALID", "尚未进入创建订单边界")
|
||||
else live.readOrderResult()?.let { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = it.orderNo, orderSubmittedAt = it.submittedAt, actualUnitPriceCent = observedPrice) }
|
||||
else live.readOrderResult()?.let { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = it.orderNo, orderSubmittedAt = it.submittedAt, actualUnitPriceCent = observedPrice, pddOrderAmountCent = it.pddOrderAmountCent) }
|
||||
?: live.lastOrderReadFailure.let { readFailure ->
|
||||
PurchaseExecutionOutcome(
|
||||
"order_result_unknown",
|
||||
readFailure?.code ?: "PURCHASE_ORDER_RESULT_UNKNOWN",
|
||||
readFailure?.message ?: "无法确认订单是否创建,请人工检查",
|
||||
actualUnitPriceCent = observedPrice,
|
||||
paymentPageObserved = readFailure?.paymentPageObserved ?: false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -388,7 +394,7 @@ class PurchaseRehearsalExecutor(
|
||||
if (wait.changed) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -409,7 +415,7 @@ class PurchaseRehearsalExecutor(
|
||||
if (wait.changed) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
|
||||
)
|
||||
}
|
||||
return failure(
|
||||
@@ -440,17 +446,51 @@ class PurchaseRehearsalExecutor(
|
||||
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
|
||||
var last = currentScreen(input)
|
||||
var changed = false
|
||||
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
|
||||
var polls = 0
|
||||
var waitedMs = 0L
|
||||
var requiredEvidenceSeen = false
|
||||
var previousStructure: List<SpecActionNodeSignature>? = null
|
||||
fun waitDiagnostic(outcome: String) {
|
||||
if (requiredEvidenceSeen) {
|
||||
panelDiagnostic(
|
||||
"specPanelWait;outcome=$outcome;waitedMs=$waitedMs;samples=$polls;backPressed=false;" +
|
||||
last.requiredEvidenceSummary() + last.requiredEvidenceHintSuffix(),
|
||||
)
|
||||
}
|
||||
}
|
||||
while (true) {
|
||||
last = currentScreen(input)
|
||||
panelDiagnostic(panelEvidence(last))
|
||||
if (last.reviewPageOpen) {
|
||||
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
|
||||
}
|
||||
last.problem?.let { return SpecPanelWait(last, false, true, failure(it.code, it.message)) }
|
||||
if (last.specPanelOpen) return SpecPanelWait(last, true, true)
|
||||
changed = changed || specActionSignature(last) != beforeSignature
|
||||
val structure = specActionSignature(last)
|
||||
// Stronger panel types keep their immediate acceptance. The #331
|
||||
// fallback type additionally needs two identical consecutive
|
||||
// structures so a still-loading sheet is not acted on too early.
|
||||
if (last.specPanelOpen && (last.specPanelType != SpecPanelType.REQUIRED_EVIDENCE || structure == previousStructure)) {
|
||||
waitDiagnostic("opened")
|
||||
return SpecPanelWait(last, true, true)
|
||||
}
|
||||
changed = changed || structure != beforeSignature
|
||||
requiredEvidenceSeen = requiredEvidenceSeen ||
|
||||
last.hasAddressEntry || last.hasPaymentEntry || last.hasQuantityInput
|
||||
previousStructure = structure
|
||||
pause(SPEC_SELECTION_POLL_MILLIS)
|
||||
waitedMs += SPEC_SELECTION_POLL_MILLIS
|
||||
polls++
|
||||
// A partially loaded purchase sheet may take longer than the plain
|
||||
// entry verification window; it gets the bounded #331 wait. No Back
|
||||
// is pressed while waiting; the caller fails explicitly on timeout.
|
||||
val limitReached = if (requiredEvidenceSeen) {
|
||||
waitedMs >= SpecPanelStabilityPolicy.WAIT_BOUND_MS
|
||||
} else {
|
||||
polls >= SPEC_POST_CLICK_VERIFY_POLLS
|
||||
}
|
||||
if (limitReached) break
|
||||
}
|
||||
waitDiagnostic("timeout")
|
||||
return SpecPanelWait(last, false, changed)
|
||||
}
|
||||
|
||||
@@ -472,7 +512,8 @@ class PurchaseRehearsalExecutor(
|
||||
private fun panelEvidence(screen: ParsedPddScreen): String =
|
||||
"type=${screen.specPanelType};scrollables=${screen.panelScrollableCount};headings=${screen.panelHeadingCount};" +
|
||||
"options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};" +
|
||||
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched}"
|
||||
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched};" +
|
||||
screen.requiredEvidenceSummary()
|
||||
|
||||
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int, entryReadyWaitPolls: Int = 0): String =
|
||||
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
|
||||
@@ -568,9 +609,11 @@ class PurchaseRehearsalExecutor(
|
||||
verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
|
||||
verification.failure?.let { return it }
|
||||
if (verification.confirmed) return null
|
||||
panelDiagnostic(selectionFailureEvidence(currentScreen(input), dimension, target))
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "无障碍点击和受控手势后未能确认精确选中状态")
|
||||
}
|
||||
if (outcome.result == FreshActionResult.SUCCESS) {
|
||||
panelDiagnostic(selectionFailureEvidence(currentScreen(input), dimension, target))
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "点击规格后未能确认精确选中状态")
|
||||
}
|
||||
val subreason = when (outcome.reason) {
|
||||
@@ -583,6 +626,19 @@ class PurchaseRehearsalExecutor(
|
||||
return failure(SPEC_CLICK_FAILED, subreason)
|
||||
}
|
||||
|
||||
/**
|
||||
* #332/task 535: parsed option texts and selected flags of one dimension.
|
||||
* Spec values are product attributes, not personal data; each is truncated.
|
||||
*/
|
||||
private fun selectionFailureEvidence(screen: ParsedPddScreen, dimension: String, target: String): String {
|
||||
fun clip(value: String) = value.replace(Regex("[\r\n\t;|]+"), " ").take(SELECTION_DIAGNOSTIC_TEXT_CHARS)
|
||||
val values = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
return "specSelectionUnconfirmed;dimension=$dimension;target=${clip(target)};valueCount=${values.size};values=" +
|
||||
values.joinToString("|") { value ->
|
||||
"${clip(value.text)}:${(value.node.selected || value.node.checked).toString().first()}"
|
||||
}
|
||||
}
|
||||
|
||||
private data class ExactSelectionWait(
|
||||
val confirmed: Boolean,
|
||||
val failure: PurchaseExecutionOutcome? = null,
|
||||
@@ -1169,6 +1225,7 @@ class PurchaseRehearsalExecutor(
|
||||
private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20
|
||||
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
|
||||
private const val SPEC_SELECTION_POLL_MILLIS = 100L
|
||||
private const val SELECTION_DIAGNOSTIC_TEXT_CHARS = 40
|
||||
private const val SOLD_OUT_PANEL_CLOSE_MILLIS = 300L
|
||||
private const val SPEC_ENTRY_NOT_FOUND = "PURCHASE_SPEC_ENTRY_NOT_FOUND"
|
||||
private const val SPEC_ENTRY_TARGET_AMBIGUOUS = "PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS"
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* #334: a spec probe that produced zero color/size dimensions, or that never
|
||||
* collected any dimensions because the collector itself failed (e.g. an
|
||||
* over-swiped spec panel losing its headings — see
|
||||
* [PddProductDetailCollector.moveSpecPanelToTop]), must fail explicitly
|
||||
* rather than being reported as a normal `spec_probe_completed` empty
|
||||
* result or a generic "no matchable spec" outcome.
|
||||
*
|
||||
* This lives outside [PurchaseRehearsalExecutor] on purpose: the executor's
|
||||
* `probeSpecs` callback is opaque by contract (any JSON payload the caller
|
||||
* wants to hand to the server), and its own unit tests exercise that
|
||||
* contract with canned payloads unrelated to this device bug. The checks
|
||||
* instead run once, at the point where
|
||||
* [cn.ilapage.goauto.agent.service.AgentForegroundService] turns the real
|
||||
* collector result into the outcome that gets persisted and reported.
|
||||
* `collectPurchaseProbe` encodes a failed collector run as a
|
||||
* `probeFailureCode`/`probeFailureMessage` JSON payload (still routed
|
||||
* through the same opaque `String?` probe callback) instead of `null`, so
|
||||
* [demote] can tell it apart from a genuinely empty-but-successful probe.
|
||||
*/
|
||||
object PurchaseSpecProbePolicy {
|
||||
const val EMPTY_PROBE_CODE = "PURCHASE_SPEC_PROBE_EMPTY"
|
||||
const val EMPTY_PROBE_MESSAGE = "规格探测未读取到任何颜色或尺码"
|
||||
const val FAILED_PROBE_CODE = "PURCHASE_SPEC_PROBE_FAILED"
|
||||
|
||||
/** JSON field names used by [collectPurchaseProbeFailurePayload]-style encodings. */
|
||||
const val PROBE_FAILURE_CODE_FIELD = "probeFailureCode"
|
||||
const val PROBE_FAILURE_MESSAGE_FIELD = "probeFailureMessage"
|
||||
|
||||
/** Purchaser-readable messages for known collector failure codes; anything else falls back to a generic message that still includes the raw code. */
|
||||
private val knownCollectorFailureMessages = mapOf(
|
||||
"SPEC_PANEL_TOP_COLLAPSED" to "规格探测时规格面板被拖动,规格标题消失",
|
||||
)
|
||||
|
||||
/** True when the probe JSON's `dimensions` array has no entries, or entries with no values. */
|
||||
fun isDimensionsEmpty(probedSpecsJson: String?): Boolean {
|
||||
if (probedSpecsJson.isNullOrBlank()) return true
|
||||
return try {
|
||||
val dimensions = JSONObject(probedSpecsJson).optJSONArray("dimensions") ?: return true
|
||||
var total = 0
|
||||
for (i in 0 until dimensions.length()) {
|
||||
total += dimensions.optJSONObject(i)?.optJSONArray("values")?.length() ?: 0
|
||||
}
|
||||
total == 0
|
||||
} catch (_: Exception) {
|
||||
// Malformed payload cannot be trusted as a real, non-empty probe result.
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts a collector failure code from a probe JSON payload built by [encodeCollectorFailure], or null when the payload is not a failure encoding. */
|
||||
fun extractCollectorFailureCode(probedSpecsJson: String?): String? {
|
||||
if (probedSpecsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
JSONObject(probedSpecsJson).optString(PROBE_FAILURE_CODE_FIELD, "").takeIf(String::isNotBlank)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the opaque probe JSON payload used to carry an explicit collector failure through the `probeSpecs: () -> String?` callback. */
|
||||
fun encodeCollectorFailure(goodsId: String, collectorCode: String, collectorMessage: String): String =
|
||||
JSONObject()
|
||||
.put("goodsId", goodsId)
|
||||
.put(PROBE_FAILURE_CODE_FIELD, collectorCode)
|
||||
.put(PROBE_FAILURE_MESSAGE_FIELD, collectorMessage)
|
||||
.toString()
|
||||
|
||||
private fun failureMessageFor(collectorCode: String): String =
|
||||
knownCollectorFailureMessages[collectorCode] ?: "商品规格探测失败:$collectorCode"
|
||||
|
||||
/**
|
||||
* Demotes a `spec_probe_completed` outcome that either carries an
|
||||
* encoded collector failure, or collected zero dimensions, into an
|
||||
* explicit failure. Any other outcome (including real failures reported
|
||||
* some other way, or a probe that did collect dimensions) is returned
|
||||
* unchanged.
|
||||
*/
|
||||
fun demote(outcome: PurchaseExecutionOutcome): PurchaseExecutionOutcome {
|
||||
if (outcome.resultType != "spec_probe_completed") return outcome
|
||||
extractCollectorFailureCode(outcome.probedSpecs)?.let { collectorCode ->
|
||||
return PurchaseExecutionOutcome("failed", FAILED_PROBE_CODE, failureMessageFor(collectorCode))
|
||||
}
|
||||
if (!isDimensionsEmpty(outcome.probedSpecs)) return outcome
|
||||
return PurchaseExecutionOutcome("failed", EMPTY_PROBE_CODE, EMPTY_PROBE_MESSAGE)
|
||||
}
|
||||
|
||||
/** @see demote */
|
||||
@Deprecated("Use demote(outcome), which also handles encoded collector failures.", ReplaceWith("demote(outcome)"))
|
||||
fun demoteIfEmpty(outcome: PurchaseExecutionOutcome): PurchaseExecutionOutcome = demote(outcome)
|
||||
}
|
||||
@@ -150,11 +150,22 @@ object AgentCapabilities {
|
||||
const val PDD_PRODUCT_DETAIL_V1 = "collector.pdd.product-detail.v1"
|
||||
const val PDD_CURRENT_PAGE_SHARE_V1 = "collector.pdd.current-page-share.v1"
|
||||
|
||||
/**
|
||||
* 图搜采集能力(#277)。
|
||||
*
|
||||
* `[必须]` 必须与服务端 `task.ImageSearchCapability` 逐字一致。
|
||||
* `taskCompatible` 对 source='image_search' 的任务按该能力过滤:
|
||||
* 设备不声明则图搜任务**永远不会被领取**,且是静默的——
|
||||
* 任务停在 pending、不失败也不报错。
|
||||
*/
|
||||
const val PDD_IMAGE_SEARCH_V1 = "pdd.image-search.v1"
|
||||
|
||||
val supported: List<String> = listOf(
|
||||
SCHEMA_V2,
|
||||
SWIPE_V1,
|
||||
PDD_PRODUCT_DETAIL_V1,
|
||||
PDD_CURRENT_PAGE_SHARE_V1,
|
||||
PDD_IMAGE_SEARCH_V1,
|
||||
PurchaseAgentCapabilities.REHEARSAL_V1,
|
||||
PurchaseAgentCapabilities.LIVE_V1,
|
||||
PurchaseAgentCapabilities.SPEC_PROBE_V1,
|
||||
|
||||
@@ -211,6 +211,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))
|
||||
}
|
||||
@@ -334,6 +337,32 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载图搜参考图字节内容(#277)。只做网络传输,不做任何校验——校验交给
|
||||
* `ImageSearchAssetPolicy.valid`(sha256/大小/JPEG 标记),保持单一职责。
|
||||
*/
|
||||
fun downloadImageSearchReferenceImage(reference: ImageSearchImage, token: String): ByteArray {
|
||||
val url = if (reference.imageUrl.startsWith("http")) reference.imageUrl else serverUrl + reference.imageUrl
|
||||
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
connectTimeout = 10_000
|
||||
readTimeout = 20_000
|
||||
useCaches = false
|
||||
setRequestProperty("Authorization", "Bearer $token")
|
||||
}
|
||||
return try {
|
||||
val status = connection.responseCode
|
||||
if (status !in 200..299) {
|
||||
val body = connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
val json = runCatching { JSONObject(body) }.getOrElse { JSONObject() }
|
||||
throw AgentApiException(status, json.optString("code", "HTTP_$status"), json.optString("message", "图搜参考图下载失败"), status >= 500)
|
||||
}
|
||||
connection.inputStream.use { it.readBytes() }
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
fun nextTask(token: String): AgentTask? {
|
||||
val response = request("GET", "/api/agent/v1/tasks/next", null, token) ?: return null
|
||||
return task(response.getJSONObject("data"))
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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 pddOrderAmountCent: Long? = null, val warningCode: String? = null, val warningMessage: String? = null,
|
||||
) {
|
||||
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()}" +
|
||||
(warningCode?.let { ";需人工检查($it${warningMessage?.let { message -> ":$message" }.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) }
|
||||
item.pddOrderAmountCent?.let { put("pddOrderAmountCent", 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"),
|
||||
if (it.isNull("pddOrderAmountCent")) null else it.optLong("pddOrderAmountCent"),
|
||||
if (it.isNull("warningCode")) null else it.optString("warningCode"),
|
||||
if (it.isNull("warningMessage")) null else it.optString("warningMessage"))
|
||||
} }
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,10 @@ enum class AgentDiagnosticReason {
|
||||
SPEC_PANEL_RESTORE_LIMIT,
|
||||
SPEC_PANEL_RESTORE_CONTAINER_UNAVAILABLE,
|
||||
SPEC_PANEL_RESTORE_GESTURE_FAILED,
|
||||
/** #334: the spec panel already showed the topmost color heading; the top-restore swipe was skipped. */
|
||||
SPEC_PANEL_TOP_ALREADY,
|
||||
/** #334: a top-restore swipe made spec headings or dimensions vanish (the bottom sheet was dragged); swiping stopped. */
|
||||
SPEC_PANEL_TOP_COLLAPSED,
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
SPEC_ENTRY_CLICK_NO_EFFECT,
|
||||
SIZE_FOUND,
|
||||
|
||||
@@ -11,6 +11,34 @@ 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)
|
||||
.putNullable("pddOrderAmountCent", result.pddOrderAmountCent)
|
||||
.putNullable("warningCode", result.warningCode).putNullable("warningMessage", result.warningMessage)
|
||||
.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 +118,21 @@ 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("pddOrderAmountCent", result.pddOrderAmountCent ?: JSONObject.NULL)
|
||||
.put("backfillWarningCode", result.warningCode ?: JSONObject.NULL)
|
||||
.put("backfillWarningMessage", result.warningMessage ?: JSONObject.NULL)
|
||||
.put("errorCode", JSONObject.NULL).put("errorMessage", JSONObject.NULL)
|
||||
.put("retryable", false).put("retryDisabledReason", JSONObject.NULL)
|
||||
}
|
||||
|
||||
+259
-5
@@ -31,10 +31,14 @@ import cn.ilapage.goauto.agent.automation.PddLaunchResult
|
||||
import cn.ilapage.goauto.agent.automation.PddLinkLauncher
|
||||
import cn.ilapage.goauto.agent.automation.PddDetailEntryRunner
|
||||
import cn.ilapage.goauto.agent.automation.PddProductDetailCollector
|
||||
import cn.ilapage.goauto.agent.automation.PinduoduoImageSearchAssetStore
|
||||
import cn.ilapage.goauto.agent.automation.PinduoduoImageSearchAutomation
|
||||
import cn.ilapage.goauto.agent.automation.PreparedImageSearchAsset
|
||||
import cn.ilapage.goauto.agent.automation.PageEvidence
|
||||
import cn.ilapage.goauto.agent.automation.NodeSelector
|
||||
import cn.ilapage.goauto.agent.automation.CollectionRule
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
|
||||
@@ -75,6 +79,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 +137,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 +160,7 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
backfillGuard.cancelled.set(true)
|
||||
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
|
||||
cancelIdleReturn("服务已停止")
|
||||
collectionCooldownFuture.getAndSet(null)?.cancel(false)
|
||||
@@ -165,7 +179,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 +321,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?,
|
||||
@@ -414,6 +520,31 @@ class AgentForegroundService : Service() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 把采购员看得懂的文案和内部原因拼在一起:文案在前,原始说法括注在后。
|
||||
*
|
||||
* 内部原因只描述页面形态与归位结果,不含凭据、账号、订单和个人数据(#294)。
|
||||
*/
|
||||
private fun imageSearchFailureMessage(code: String, internal: String): String {
|
||||
if (!code.startsWith("IMAGE_SEARCH")) return internal
|
||||
val user = imageSearchUserMessage(code)
|
||||
return if (internal.isBlank() || internal == user) user else "$user[$internal]"
|
||||
}
|
||||
|
||||
/** #277:图搜任务失败码到人话文案的映射,采购员看得懂即可,不暴露内部状态机细节。 */
|
||||
private fun imageSearchUserMessage(code: String): String = when (code) {
|
||||
"IMAGE_SEARCH_PERMISSION_REQUIRED" -> "未授予相册权限,无法准备图搜参考图,请到“状态”页开启相册权限后重试。"
|
||||
"IMAGE_SEARCH_ENTRY_NOT_FOUND" -> "打不开拍照搜索入口,请检查 PDD App 版本或手动确认后重试。"
|
||||
"IMAGE_SEARCH_RESULTS_NOT_READY" -> "图搜结果页没有如期出现,请稍后重试。"
|
||||
"IMAGE_SEARCH_CANDIDATE_CLICK_FAILED" -> "搜到了商品但打不开,请稍后重试或人工关联。"
|
||||
"IMAGE_SEARCH_NO_CANDIDATES" -> "没有搜到相似商品,请确认参考图清晰或换一张图后重试。"
|
||||
"IMAGE_SEARCH_CAMERA_BLOCKED" -> "拼多多摄像头一直识别不到东西,图搜页反复弹出重试提示。请把手机对着有内容的画面后重试。"
|
||||
"IMAGE_SEARCH_ASSET_INVALID" -> "图片准备失败,请重新发起图搜任务。"
|
||||
"IMAGE_SEARCH_ASSET_NOT_LATEST" -> "相册里有更新的照片或视频,无法确认图搜会用到我们准备的图片,请清理相册后重试。"
|
||||
"IMAGE_SEARCH_AUTOMATION_UNAVAILABLE" -> "当前 Agent 版本尚未接入 PDD 图搜自动化,请使用普通采集任务。"
|
||||
else -> "图搜任务失败,请稍后重试。"
|
||||
}
|
||||
|
||||
private fun currentPageUserMessage(code: String, fallback: String): String = when (code) {
|
||||
"DEVICE_BUSY" -> "设备正在执行任务,请稍后再试。"
|
||||
"AGENT_MANUAL_RULE_NOT_CONFIGURED" -> "请先配置 Agent 手动采集规则。"
|
||||
@@ -461,7 +592,7 @@ class AgentForegroundService : Service() {
|
||||
stateStore.update("BUSY", "正在执行${taskLabel}任务 #${task.taskId}", tokenStored = true)
|
||||
updateNotification("$taskLabel #${task.taskId}")
|
||||
|
||||
val outcome = if (!snapshotHashValid) {
|
||||
val rawOutcome = if (!snapshotHashValid) {
|
||||
PurchaseExecutionOutcome("failed", "PURCHASE_RULE_INVALID", "采购规则快照哈希无效")
|
||||
} else {
|
||||
var parseFailure: PurchaseExecutionOutcome? = null
|
||||
@@ -522,6 +653,9 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// #334: a spec probe that read zero colors/sizes must fail explicitly
|
||||
// instead of being reported as a normal, empty spec_probe_completed.
|
||||
val outcome = PurchaseSpecProbePolicy.demote(rawOutcome)
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
|
||||
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
|
||||
@@ -561,6 +695,13 @@ class AgentForegroundService : Service() {
|
||||
collector = collector,
|
||||
)
|
||||
val result = PddProductDetailCollector(accessibility).collect(task.pddGoodsId, rule)
|
||||
// #334: a collector failure (e.g. SPEC_PANEL_TOP_COLLAPSED) must not
|
||||
// collapse into a generic "spec probe failed" outcome. Carry the
|
||||
// real code/message through the opaque probeSpecs callback so
|
||||
// PurchaseSpecProbePolicy.demote can surface it explicitly.
|
||||
if (!result.successful) {
|
||||
return PurchaseSpecProbePolicy.encodeCollectorFailure(task.pddGoodsId, result.code, result.message)
|
||||
}
|
||||
val payload = result.payload ?: return null
|
||||
return JSONObject()
|
||||
.put("goodsId", task.pddGoodsId)
|
||||
@@ -591,9 +732,10 @@ class AgentForegroundService : Service() {
|
||||
"order_result_unknown",
|
||||
readFailure?.code ?: "PURCHASE_ORDER_RESULT_UNKNOWN",
|
||||
readFailure?.message ?: "无法确认订单是否创建,请人工检查",
|
||||
paymentPageObserved = readFailure?.paymentPageObserved ?: false,
|
||||
)
|
||||
} else {
|
||||
PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt)
|
||||
PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt, pddOrderAmountCent = evidence.pddOrderAmountCent)
|
||||
}
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
purchaseStore.completeAndEnqueue(interrupted.taskId, interrupted.attemptId, requestId, purchaseResultPayload(requestId, interrupted.attemptId, outcome))
|
||||
@@ -709,7 +851,88 @@ class AgentForegroundService : Service() {
|
||||
if (!collection.successful) throw TaskFailure(collection.code, collection.message)
|
||||
CollectionExecution(requireNotNull(collection.payload), collection.colorImages)
|
||||
} else if (task.source == "image_search") {
|
||||
throw TaskFailure("IMAGE_SEARCH_AUTOMATION_UNAVAILABLE", "当前 Agent 版本尚未接入 PDD 图搜自动化,请使用普通采集任务")
|
||||
// #277:相册权限是图搜可执行的前置条件。
|
||||
if (cn.ilapage.goauto.agent.ui.MediaPermissionPolicy.current(this) ==
|
||||
cn.ilapage.goauto.agent.ui.MediaPermissionReadiness.NOT_GRANTED
|
||||
) {
|
||||
throw TaskFailure("IMAGE_SEARCH_PERMISSION_REQUIRED", imageSearchUserMessage("IMAGE_SEARCH_PERMISSION_REQUIRED"))
|
||||
}
|
||||
if (rule.schemaVersion != 2) {
|
||||
throw TaskFailure("RULE_INVALID", "图搜采集只支持新版采集规则")
|
||||
}
|
||||
val reference = task.imageSearch
|
||||
?: throw TaskFailure("IMAGE_SEARCH_ASSET_INVALID", imageSearchUserMessage("IMAGE_SEARCH_ASSET_INVALID"))
|
||||
val assetStore = PinduoduoImageSearchAssetStore(this)
|
||||
var prepared: PreparedImageSearchAsset? = null
|
||||
try {
|
||||
when (accessibility.restoreRecentPddPage(::launchPddToForeground)) {
|
||||
PddForegroundRestoreResult.READY -> Unit
|
||||
PddForegroundRestoreResult.INTENT_MISSING ->
|
||||
throw TaskFailure("PDD_APP_UNAVAILABLE", "没有找到拼多多应用,请确认已安装")
|
||||
PddForegroundRestoreResult.LAUNCH_FAILED ->
|
||||
throw TaskFailure("PDD_FOREGROUND_LAUNCH_FAILED", "无法打开拼多多,请手动打开商品页后重试")
|
||||
PddForegroundRestoreResult.TIMEOUT ->
|
||||
throw TaskFailure("CURRENT_PDD_PAGE_NOT_FOUND", "拼多多未能切换到前台")
|
||||
}
|
||||
val bytes = try {
|
||||
api.downloadImageSearchReferenceImage(reference, token)
|
||||
} catch (error: AgentApiException) {
|
||||
throw TaskFailure("IMAGE_SEARCH_ASSET_INVALID", imageSearchUserMessage("IMAGE_SEARCH_ASSET_INVALID"))
|
||||
}
|
||||
prepared = assetStore.prepare(bytes, reference)
|
||||
?: throw TaskFailure("IMAGE_SEARCH_ASSET_INVALID", imageSearchUserMessage("IMAGE_SEARCH_ASSET_INVALID"))
|
||||
if (!assetStore.isMostRecent(prepared)) {
|
||||
throw TaskFailure("IMAGE_SEARCH_ASSET_NOT_LATEST", imageSearchUserMessage("IMAGE_SEARCH_ASSET_NOT_LATEST"))
|
||||
}
|
||||
val imageSearch = PinduoduoImageSearchAutomation(accessibility, accessibility::currentPackage)
|
||||
// `[必须]` 归位与选图是同一个调用:中间多一次无障碍树抓取就可能错过
|
||||
// 拼多多摄像头自动弹出重试弹窗前的约 4 秒窗口,详见 enterAndSelectImage。
|
||||
val entry = imageSearch.enterAndSelectImage(::launchPddToForeground, ::resetPddToHome)
|
||||
// `[必须]` 保留归位状态机的原始说法。IMAGE_SEARCH_ENTRY_NOT_FOUND 有两个
|
||||
// 来源——「找不到唯一可点的拍照搜索入口」和「归位失败(预算耗尽)」——
|
||||
// 统一文案会把两者抹平,排查任务 156 时只能靠失败耗时反推(#294)。
|
||||
// 括注写法与下面候选点击失败一致。
|
||||
if (!entry.successful) throw TaskFailure(entry.code, imageSearchFailureMessage(entry.code, entry.message))
|
||||
if (!imageSearch.waitForResultsPage()) {
|
||||
throw TaskFailure("IMAGE_SEARCH_RESULTS_NOT_READY", imageSearchUserMessage("IMAGE_SEARCH_RESULTS_NOT_READY"))
|
||||
}
|
||||
val candidate = imageSearch.openFirstCandidate()
|
||||
if (!candidate.successful) {
|
||||
// 保留执行器给出的具体原因(含 result/reason),排查时不必再猜卡在哪一步。
|
||||
throw TaskFailure(candidate.code, "${imageSearchUserMessage(candidate.code)}[${candidate.message}]")
|
||||
}
|
||||
// 接回现有的分享链接识别 + 商品详情采集链路(与 agent_current_page 完全一致)。
|
||||
val identityResult = CurrentPageIdentityRunner(
|
||||
driver = accessibility,
|
||||
readFreshClipboard = { copiedAt, timeout ->
|
||||
ClipboardRelayActivity.readFresh(this, copiedAt, timeout)
|
||||
},
|
||||
taskId = task.taskId,
|
||||
diagnostic = diagnosticRecorder::record,
|
||||
).identify(rule)
|
||||
if (!identityResult.successful) throw TaskFailure(identityResult.code, identityResult.message)
|
||||
val identity = try {
|
||||
api.identifyCurrentPageCollectionTask(
|
||||
task.taskId,
|
||||
UUID.randomUUID().toString(),
|
||||
requireNotNull(identityResult.shareUrl),
|
||||
token,
|
||||
)
|
||||
} catch (error: AgentApiException) {
|
||||
throw TaskFailure(error.code, error.message)
|
||||
}
|
||||
val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) }
|
||||
val collection = PddProductDetailCollector(
|
||||
accessibility,
|
||||
trace = trace,
|
||||
taskId = task.taskId,
|
||||
diagnostic = diagnosticRecorder::record,
|
||||
).collect(identity.goodsId, rule)
|
||||
if (!collection.successful) throw TaskFailure(collection.code, collection.message)
|
||||
CollectionExecution(requireNotNull(collection.payload), collection.colorImages)
|
||||
} finally {
|
||||
assetStore.delete(prepared)
|
||||
}
|
||||
} else if (rule.schemaVersion == 2) {
|
||||
val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) }
|
||||
val collection = PddDetailEntryRunner(
|
||||
@@ -1040,10 +1263,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()
|
||||
@@ -1054,6 +1282,27 @@ class AgentForegroundService : Service() {
|
||||
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(content))
|
||||
}
|
||||
|
||||
/**
|
||||
* 把拼多多的返回栈清到根活动,等于回首页。
|
||||
*
|
||||
* `[必须]` 不能复用 [launchPddToForeground]:那个只带 FLAG_ACTIVITY_NEW_TASK,
|
||||
* 会把已有任务**连同深层页面**一起恢复到前台,采集当前页的流程正需要这个行为。
|
||||
* 归位要的是相反的效果,所以单独加 FLAG_ACTIVITY_CLEAR_TOP,两条路径各自保留(#292)。
|
||||
*/
|
||||
private fun resetPddToHome(): PddLaunchResult {
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE)
|
||||
?: return PddLaunchResult.INTENT_MISSING
|
||||
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
return runCatching { startActivity(launchIntent) }
|
||||
.fold(
|
||||
onSuccess = { PddLaunchResult.STARTED },
|
||||
onFailure = {
|
||||
Log.w("GoAutoAgent", "failed to reset PDD to home", it)
|
||||
PddLaunchResult.FAILED
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun launchPddToForeground(): PddLaunchResult {
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE)
|
||||
?: return PddLaunchResult.INTENT_MISSING
|
||||
@@ -1069,6 +1318,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"
|
||||
|
||||
@@ -104,6 +104,10 @@ internal enum class TaskDispatchDecision {
|
||||
CHECK_COLLECTION,
|
||||
}
|
||||
|
||||
// 采集 > 图搜的优先级不在这里实现,也无法在这里实现:图搜任务就是 source =
|
||||
// "image_search" 的普通采集任务,由同一个 /tasks/next 端点下发,Agent 调用时
|
||||
// 服务端已经选好了行。该排序落在服务端 task.Service.Next 的 ORDER BY 里(#277)。
|
||||
|
||||
internal object TaskDispatchPolicy {
|
||||
fun decide(purchaseStatus: String?, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
|
||||
purchaseStatus == "spec_probe_pending" -> TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -16,9 +16,16 @@ internal fun purchaseResultPayload(
|
||||
put("errorCode", outcome.errorCode ?: "AGENT_EXECUTION_ERROR")
|
||||
put("errorMessage", outcome.message.take(1000))
|
||||
}
|
||||
// 仅 order_result_unknown 有意义;服务端只在这个结果类型下读取,其它类型忽略。
|
||||
if (outcome.resultType == "order_result_unknown" && outcome.paymentPageObserved) {
|
||||
put("paymentPageObserved", true)
|
||||
}
|
||||
outcome.probedSpecs?.let { put("probedSpecs", JSONObject(it)) }
|
||||
outcome.pddOrderNo?.let { put("pddOrderNo", it) }
|
||||
outcome.orderSubmittedAt?.let { put("orderSubmittedAt", it) }
|
||||
outcome.actualUnitPriceCent?.let { put("actualUnitPriceCent", it) }
|
||||
if (outcome.resultType == "order_created") {
|
||||
outcome.pddOrderAmountCent?.let { put("pddOrderAmountCent", it) }
|
||||
}
|
||||
}
|
||||
.toString()
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package cn.ilapage.goauto.agent.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
|
||||
|
||||
@@ -51,3 +56,36 @@ object SettingsAvailabilityResolver {
|
||||
fun editable(stateCode: String, currentTaskId: Long?, testing: Boolean): Boolean =
|
||||
!testing && stateCode != "BUSY" && currentTaskId == null
|
||||
}
|
||||
|
||||
/**
|
||||
* 相册权限状态(#276):`MainActivity.onCreate` 只在首次无条件弹一次系统授权框,
|
||||
* 用户拒绝(尤其"不再询问")后没有任何补救入口。这里补一个可复用的状态查询 +
|
||||
* 引导跳转,供设置/状态页和图搜执行器共用,模式与 [AccessibilityReadinessDetector] 保持一致。
|
||||
*/
|
||||
enum class MediaPermissionReadiness {
|
||||
GRANTED,
|
||||
NOT_GRANTED,
|
||||
}
|
||||
|
||||
object MediaPermissionPolicy {
|
||||
/** 供图搜执行器调用的只读查询:未授权时应返回明确失败原因,不弹窗、不静默失败。 */
|
||||
fun current(context: Context): MediaPermissionReadiness {
|
||||
val permission = requiredPermission()
|
||||
val granted = context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
||||
return if (granted) MediaPermissionReadiness.GRANTED else MediaPermissionReadiness.NOT_GRANTED
|
||||
}
|
||||
|
||||
fun requiredPermission(): String =
|
||||
if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES
|
||||
else Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
|
||||
/**
|
||||
* "不再询问"之后系统弹窗不会再出现,只能引导用户去应用详情页手动开启。
|
||||
* 调用方在 `shouldShowRequestPermissionRationale` 返回 false 且权限仍未授予时使用本方法。
|
||||
*/
|
||||
fun appDetailsSettingsIntent(context: Context): Intent =
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +70,17 @@ class AgentStatusFragment : Fragment() {
|
||||
private lateinit var taskText: TextView
|
||||
private lateinit var accessibilityTitle: TextView
|
||||
private lateinit var accessibilityDetail: TextView
|
||||
private lateinit var mediaPermissionTitle: TextView
|
||||
private lateinit var mediaPermissionDetail: TextView
|
||||
private lateinit var mediaPermissionAction: android.widget.Button
|
||||
private lateinit var deviceText: TextView
|
||||
private val mediaPermissionRequestLauncher by lazy {
|
||||
registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.RequestPermission()) {
|
||||
refreshStatus()
|
||||
}
|
||||
}
|
||||
private var receiverRegistered = false
|
||||
private var mediaPermissionRequestedOnce = false
|
||||
|
||||
private val refresh = object : Runnable {
|
||||
override fun run() {
|
||||
@@ -144,6 +153,21 @@ class AgentStatusFragment : Fragment() {
|
||||
addView(accessibilityTitle)
|
||||
addView(accessibilityDetail)
|
||||
}))
|
||||
addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label("相册权限(图搜用)", 14f, context.getColor(R.color.agent_text_muted)))
|
||||
mediaPermissionTitle = context.label("正在检查", 18f, context.getColor(R.color.agent_warning), true)
|
||||
mediaPermissionDetail = context.label("—", 14f, context.getColor(R.color.agent_text_muted)).apply {
|
||||
setPadding(0, context.dp(6), 0, 0)
|
||||
}
|
||||
addView(mediaPermissionTitle)
|
||||
addView(mediaPermissionDetail)
|
||||
mediaPermissionAction = android.widget.Button(context).apply {
|
||||
text = "重新授权"
|
||||
setPadding(0, context.dp(8), 0, 0)
|
||||
setOnClickListener { requestMediaPermission() }
|
||||
}
|
||||
addView(mediaPermissionAction)
|
||||
}))
|
||||
}
|
||||
refreshLayout = SwipeRefreshLayout(context).apply {
|
||||
setColorSchemeResources(R.color.agent_primary_light)
|
||||
@@ -258,10 +282,44 @@ class AgentStatusFragment : Fragment() {
|
||||
}
|
||||
accessibilityDetail.text = AgentStatusTextPolicy.accessibilityDetail(readiness).orEmpty()
|
||||
accessibilityDetail.visibility = if (accessibilityDetail.text.isEmpty()) View.GONE else View.VISIBLE
|
||||
val mediaReadiness = MediaPermissionPolicy.current(context)
|
||||
when (mediaReadiness) {
|
||||
MediaPermissionReadiness.GRANTED -> {
|
||||
mediaPermissionTitle.text = "已授权"
|
||||
mediaPermissionTitle.setTextColor(context.getColor(R.color.agent_primary_light))
|
||||
mediaPermissionDetail.text = "图搜任务可以正常准备参考图"
|
||||
mediaPermissionAction.visibility = View.GONE
|
||||
}
|
||||
MediaPermissionReadiness.NOT_GRANTED -> {
|
||||
mediaPermissionTitle.text = "未授权"
|
||||
mediaPermissionTitle.setTextColor(context.getColor(R.color.agent_error))
|
||||
mediaPermissionDetail.text = "未授权时图搜任务会直接失败,不影响普通采集/采购"
|
||||
mediaPermissionAction.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
val displayName = settingsStore.deviceName().ifBlank { "本机" }
|
||||
deviceText.text = "$displayName · ${state.deviceId.takeIf { it > 0 }?.let { "#$it" } ?: "未注册"}"
|
||||
}
|
||||
|
||||
private fun requestMediaPermission() {
|
||||
val context = requireContext()
|
||||
val permission = MediaPermissionPolicy.requiredPermission()
|
||||
if (shouldShowRequestPermissionRationale(permission)) {
|
||||
mediaPermissionRequestLauncher.launch(permission)
|
||||
return
|
||||
}
|
||||
// 首次请求,或用户已选择"不再询问":先尝试系统弹窗;若此前已经拒绝过一次且
|
||||
// rationale 也不再展示(即"不再询问"状态),跳转到应用详情页由用户手动开启。
|
||||
if (context.checkSelfPermission(permission) != android.content.pm.PackageManager.PERMISSION_GRANTED &&
|
||||
!mediaPermissionRequestedOnce
|
||||
) {
|
||||
mediaPermissionRequestedOnce = true
|
||||
mediaPermissionRequestLauncher.launch(permission)
|
||||
} else {
|
||||
startActivity(MediaPermissionPolicy.appDetailsSettingsIntent(context))
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerCheckReceiver() {
|
||||
if (receiverRegistered) return
|
||||
val filter = IntentFilter(AgentForegroundService.ACTION_CHECK_RESULT)
|
||||
|
||||
@@ -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) {
|
||||
@@ -427,6 +518,11 @@ class TaskHistoryFragment : Fragment() {
|
||||
return context.card(context.cardColumn().apply {
|
||||
val goodsLabel = item.goodsId.ifBlank { "未识别商品" }
|
||||
addView(context.label("#${item.taskId} · 第 ${item.attemptNumber} 次 · $goodsLabel", 17f, context.getColor(R.color.agent_text), true))
|
||||
// #279:图搜任务和普通采集混在同一列表里,加一个行内标记让采购员看出这条商品是机器图搜找到的,
|
||||
// 不改变既有 `collection` 布尔分叉的界面结构。
|
||||
if (item.source == "image_search") {
|
||||
addView(context.label("来源:图搜自动匹配", 13f, context.getColor(R.color.agent_text_muted)).apply { setPadding(0, context.dp(2), 0, 0) })
|
||||
}
|
||||
addView(context.label("$status · ${formatTime(item.finishedAt ?: item.createdAt)}", 13f, statusColor(item.status)).apply { setPadding(0, context.dp(4), 0, 0) })
|
||||
addView(context.label(summary, 14f, context.getColor(R.color.agent_text_muted)).apply { setPadding(0, context.dp(8), 0, 0) })
|
||||
if (CollectionResetPolicy.showsListAction(item.status)) {
|
||||
@@ -632,6 +728,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 +821,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,27 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.AgentCapabilities
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AgentCapabilitiesTest {
|
||||
|
||||
// #277: 服务端 taskCompatible 对 source='image_search' 的任务按这个能力过滤。
|
||||
// 漏声明时图搜任务永远不会被领取,而且是静默的:任务停在 pending、
|
||||
// 不失败也不报错。字符串必须与 task.ImageSearchCapability 逐字一致。
|
||||
@Test
|
||||
fun `supported capabilities include the image search capability the server gates on`() {
|
||||
assertTrue(
|
||||
"AgentCapabilities.supported 缺少 pdd.image-search.v1,图搜任务将无法被领取",
|
||||
AgentCapabilities.supported.contains("pdd.image-search.v1"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `supported capabilities have no duplicates`() {
|
||||
assertTrue(
|
||||
"能力清单出现重复项: " + AgentCapabilities.supported,
|
||||
AgentCapabilities.supported.size == AgentCapabilities.supported.toSet().size,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
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 `paid amount accepts exact label formats and ignores unrelated prices`() {
|
||||
for ((label, cents) in listOf("实付:,22.98元,(免运费)" to 2298L, "实付: 22元" to 2200L, "实付,22.9 元" to 2290L)) {
|
||||
val detail = BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n¥999.00\n$label") }.finish()
|
||||
assertEquals(cents, detail.item!!.pddOrderAmountCent)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `paid amount ambiguity or malformed candidate omits only amount`() {
|
||||
for (labels in listOf(
|
||||
"实付:22.98元\n实付:23.00元",
|
||||
"实付:22.98元\n实付:坏值元",
|
||||
"实付:22.345元",
|
||||
"实付:\n22.98元",
|
||||
"未实付:99.00元\n预计实付:88.00元",
|
||||
)) {
|
||||
val detail = BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n下单时间:2026-09-08 10:00:00\n$labels") }.finish()
|
||||
assertNotNull(detail.item)
|
||||
assertNull(labels, detail.item!!.pddOrderAmountCent)
|
||||
assertEquals("TEST-000007", detail.item!!.pddOrderNo)
|
||||
}
|
||||
val duplicate = BackfillDetailReader(zone).apply {
|
||||
accept("_cg7\n订单号:TEST-000007\n实付:22.98元")
|
||||
accept("实付:22.98元")
|
||||
}.finish()
|
||||
assertEquals(2298L, duplicate.item!!.pddOrderAmountCent)
|
||||
val reused = BackfillDetailReader(zone)
|
||||
reused.accept("_cg7\n订单号:TEST-000007\n实付:1.00元")
|
||||
assertEquals(100L, reused.finish().item!!.pddOrderAmountCent)
|
||||
reused.accept("_cg8\n订单号:TEST-000008\n实付:0元")
|
||||
assertEquals(0L, reused.finish().item!!.pddOrderAmountCent)
|
||||
reused.accept("_cg9\n订单号:TEST-000009\n实付:999999999999999999999999.99元")
|
||||
assertNull(reused.finish().item!!.pddOrderAmountCent)
|
||||
}
|
||||
|
||||
@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("估算"))
|
||||
assertNull(parsed.single().pddOrderAmountCent)
|
||||
assertNull(parsed.single().warningCode)
|
||||
assertTrue(parsed.single().copy(timeSource = "page").display().contains("页面下单时间"))
|
||||
val warning = parseBackfillResults(JSONObject("""{"items":[{"index":0,"taskId":7,"result":"already_backfilled","code":"ALREADY_BACKFILLED","status":"order_created","statusVersion":4,"pddOrderNo":"TEST-000007","pddOrderAmountCent":2298,"timeSource":"page","warningCode":"PURCHASE_BACKFILL_AMOUNT_CONFLICT","warningMessage":"已有不同实付金额,本次未覆盖","retryable":false}]}""")).single()
|
||||
assertEquals(2298L, warning.pddOrderAmountCent)
|
||||
assertTrue(warning.display().contains("PURCHASE_BACKFILL_AMOUNT_CONFLICT"))
|
||||
|
||||
val amountPayload = backfillPayload("test", listOf(item.copy(pddOrderAmountCent = 2298)))
|
||||
assertEquals(2298L, amountPayload.getJSONArray("items").getJSONObject(0).getLong("pddOrderAmountCent"))
|
||||
}
|
||||
|
||||
@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"))
|
||||
val warning = success().copy(pddOrderAmountCent = 2298, warningCode = "PURCHASE_BACKFILL_AMOUNT_CONFLICT", warningMessage = "conflict")
|
||||
cn.ilapage.goauto.agent.persistence.applyBackfillFacts(task, warning)
|
||||
assertEquals(2298L, task.getLong("pddOrderAmountCent"))
|
||||
assertEquals("PURCHASE_BACKFILL_AMOUNT_CONFLICT", task.getString("backfillWarningCode"))
|
||||
assertTrue(task.isNull("errorCode"))
|
||||
}
|
||||
|
||||
@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 }
|
||||
}
|
||||
}
|
||||
+133
-17
@@ -974,6 +974,95 @@ class PddProductDetailCollectorTest {
|
||||
assertTrue(driver.captureCount > driver.clickCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec panel already at top skips the restore swipe and still collects colors and sizes`() {
|
||||
// Default fixture state already renders the color heading first (at
|
||||
// top). #334: moveSpecPanelToTop must not issue any DOWN swipe in
|
||||
// that case, since dragging an already-top panel can lose headings
|
||||
// on a real device.
|
||||
val driver = FakeCollectorDriver(
|
||||
colorPages = listOf(listOf("红色", "蓝色")),
|
||||
sizePages = listOf(listOf("S", "M")),
|
||||
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
|
||||
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertEquals(0, driver.swipes.count { it.first == SwipeDirection.DOWN })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec panel not at top restores with the original swipe behavior before collecting`() {
|
||||
// The color heading is hidden until one DOWN (top-restore) swipe has
|
||||
// happened, simulating a panel scrolled past its headings.
|
||||
val driver = FakeCollectorDriver(
|
||||
colorPages = listOf(listOf("红色", "蓝色")),
|
||||
sizePages = listOf(listOf("S", "M")),
|
||||
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
|
||||
specPanelHidesColorInitially = true,
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
|
||||
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertTrue(driver.swipes.count { it.first == SwipeDirection.DOWN } >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec headings vanishing after a restore swipe stops swiping and fails explicitly`() {
|
||||
// The panel starts not-at-top (color heading hidden) so the restore
|
||||
// swipe runs; after that swipe every heading and value disappears,
|
||||
// simulating the bottom sheet being dragged off screen. #334
|
||||
// requires the collector to stop and fail instead of reporting an
|
||||
// empty successful result.
|
||||
val driver = FakeCollectorDriver(
|
||||
colorPages = listOf(listOf("红色", "蓝色")),
|
||||
sizePages = listOf(listOf("S", "M")),
|
||||
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
|
||||
specPanelHidesColorInitially = true,
|
||||
specPanelCollapsesAfterTopSwipe = true,
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertFalse(result.successful)
|
||||
assertEquals("SPEC_PANEL_TOP_COLLAPSED", result.code)
|
||||
assertEquals(1, driver.swipes.count { it.first == SwipeDirection.DOWN })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lower heading scrolling out of view after a restore swipe does not fail collection`() {
|
||||
// Three-dimension panel not at top (color heading is present but has
|
||||
// no values yet, so it does not become dimensions.first()). After the
|
||||
// restore swipe, color values return but the third, lower "容量"
|
||||
// heading scrolls out of view — a normal scroll-to-top effect. Total
|
||||
// heading count drops, but color and size values are present the
|
||||
// whole time, so this must NOT be treated as a collapsed panel.
|
||||
val driver = FakeCollectorDriver(
|
||||
colorPages = listOf(listOf("红色", "蓝色")),
|
||||
sizePages = listOf(listOf("S", "M")),
|
||||
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
|
||||
extraDimension = true,
|
||||
specPanelHidesColorInitially = true,
|
||||
specPanelDropsExtraDimensionAfterTopSwipe = true,
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
|
||||
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertTrue(driver.swipes.count { it.first == SwipeDirection.DOWN } >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate sizes after price cleanup reject the whole size dimension`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
@@ -1044,7 +1133,9 @@ class PddProductDetailCollectorTest {
|
||||
assertTrue(result.successful)
|
||||
assertEquals(listOf("A色", "B色", "C色", "F色", "E色", "D色"), driver.clickedLabels)
|
||||
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.DOWN && it.second != null })
|
||||
// #334: the panel already shows the color heading at top, so
|
||||
// moveSpecPanelToTop must not issue a needless DOWN restore swipe.
|
||||
assertTrue(driver.swipes.none { it.first == SwipeDirection.DOWN })
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.RIGHT && it.second != null })
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP && it.second != null })
|
||||
}
|
||||
@@ -1757,6 +1848,21 @@ class PddProductDetailCollectorTest {
|
||||
private val selectedSummaryPrefix: String = "已选",
|
||||
private val imageColorCards: Boolean = false,
|
||||
private val navigateAwayAfterColorClick: Boolean = false,
|
||||
// #334: simulates a spec panel that is not scrolled to top yet: the
|
||||
// color heading is present but has no values yet (so it is not the
|
||||
// first parsed dimension), until one DOWN (top-restore) swipe has
|
||||
// happened, after which color values render normally.
|
||||
private val specPanelHidesColorInitially: Boolean = false,
|
||||
// #334: simulates a bottom sheet dragged past its headings by a
|
||||
// restore swipe — once at least one DOWN swipe has happened, every
|
||||
// spec heading and value disappears from the panel.
|
||||
private val specPanelCollapsesAfterTopSwipe: Boolean = false,
|
||||
// #334: simulates a normal scroll-to-top on a multi-dimension panel
|
||||
// where a lower, non-spec-value-bearing heading (here the extra
|
||||
// "容量" dimension) scrolls out of view once the color heading is
|
||||
// restored to top. Heading count drops but color/size values are
|
||||
// untouched, so this must NOT be treated as a collapsed panel.
|
||||
private val specPanelDropsExtraDimensionAfterTopSwipe: Boolean = false,
|
||||
) : PddCollectorDriver {
|
||||
var captureCount = 0
|
||||
var clickCount = 0
|
||||
@@ -1770,6 +1876,7 @@ class PddProductDetailCollectorTest {
|
||||
private var previousSelected: String? = null
|
||||
private var horizontalPage = 0
|
||||
private var verticalPage = 0
|
||||
private var downSwipeCount = 0
|
||||
private var priceRead = 0
|
||||
private var panelOpen = !startWithPanelClosed
|
||||
private var reviewOpen = false
|
||||
@@ -1832,12 +1939,16 @@ class PddProductDetailCollectorTest {
|
||||
node("scroll", "", 0, 380, 1080, 1900, scrollable = scrollablePanel),
|
||||
)
|
||||
val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0
|
||||
val panelCollapsedNow = specPanelCollapsesAfterTopSwipe && downSwipeCount >= 1
|
||||
val hideColorNow = specPanelHidesColorInitially && downSwipeCount == 0
|
||||
val visibleColors = colorVerticalPages?.get(verticalPage.coerceAtMost(colorVerticalPages.lastIndex))
|
||||
?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
|
||||
val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize
|
||||
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
|
||||
if (!continuationPage) {
|
||||
if (!continuationPage && !panelCollapsedNow) {
|
||||
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
|
||||
}
|
||||
if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
|
||||
visibleColors
|
||||
.filterNot { hideSelectedColorOption && it == selected }
|
||||
.forEachIndexed { index, color ->
|
||||
@@ -1877,21 +1988,23 @@ class PddProductDetailCollectorTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!continuationPage) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, sizeHeadingTop, 300, sizeHeadingTop + 50, parentPath = "scroll")
|
||||
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
|
||||
nodes += node(
|
||||
"scroll/size-$size-$captureCount",
|
||||
size,
|
||||
30 + index * 230,
|
||||
sizeHeadingTop + 70,
|
||||
220 + index * 230,
|
||||
sizeHeadingTop + 140,
|
||||
clickable = true,
|
||||
selected = size == initialSelectedSize,
|
||||
parentPath = "scroll",
|
||||
)
|
||||
if (!continuationPage && !panelCollapsedNow) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, sizeHeadingTop, 300, sizeHeadingTop + 50, parentPath = "scroll")
|
||||
if (!panelCollapsedNow) {
|
||||
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
|
||||
nodes += node(
|
||||
"scroll/size-$size-$captureCount",
|
||||
size,
|
||||
30 + index * 230,
|
||||
sizeHeadingTop + 70,
|
||||
220 + index * 230,
|
||||
sizeHeadingTop + 140,
|
||||
clickable = true,
|
||||
selected = size == initialSelectedSize,
|
||||
parentPath = "scroll",
|
||||
)
|
||||
}
|
||||
}
|
||||
if (extraDimension) {
|
||||
if (extraDimension && !(specPanelDropsExtraDimensionAfterTopSwipe && downSwipeCount >= 1)) {
|
||||
nodes += node("scroll/capacity-heading", "容量", 20, 900, 300, 950, parentPath = "scroll")
|
||||
nodes += node("scroll/capacity", "大容量", 30, 970, 220, 1040, clickable = true, parentPath = "scroll")
|
||||
}
|
||||
@@ -1966,7 +2079,10 @@ class PddProductDetailCollectorTest {
|
||||
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
|
||||
SwipeDirection.UP -> verticalPage = (verticalPage + 1)
|
||||
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0))
|
||||
SwipeDirection.DOWN -> verticalPage = (verticalPage - 1).coerceAtLeast(0)
|
||||
SwipeDirection.DOWN -> {
|
||||
verticalPage = (verticalPage - 1).coerceAtLeast(0)
|
||||
downSwipeCount++
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -18,6 +18,104 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseLiveAutomationTest {
|
||||
@Test
|
||||
fun `two second payment transition and two needed scrolls yield order and payable amount without clicks`() {
|
||||
val driver = ReadOnlyOrderDriver(paymentMs = 2500)
|
||||
val order = PurchaseLiveAutomation(driver, pause = { driver.elapsed += it }).readOrderResult()
|
||||
assertEquals("PDD-DEMO-325", order?.orderNo)
|
||||
assertEquals("2026-09-19T03:21:43Z", order?.submittedAt)
|
||||
assertEquals(1300L, order?.pddOrderAmountCent)
|
||||
assertEquals(1, driver.backs)
|
||||
assertEquals(2, driver.swipes)
|
||||
assertEquals(0, driver.clicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unpaid page arriving near original limit gets independent read budget`() {
|
||||
val driver = ReadOnlyOrderDriver(preludeSamples = 58)
|
||||
val order = PurchaseLiveAutomation(driver, pause = { driver.elapsed += it }).readOrderResult()
|
||||
assertEquals("PDD-DEMO-325", order?.orderNo)
|
||||
assertTrue(driver.captures > 60)
|
||||
assertEquals(2, driver.swipes)
|
||||
assertEquals(0, driver.clicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unpaid scrolling is immediate and bounded when order never becomes visible`() {
|
||||
val driver = ReadOnlyOrderDriver(requiredSwipes = 100)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = { driver.elapsed += it })
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals(4, driver.swipes)
|
||||
assertEquals(0L, driver.firstSwipeAt)
|
||||
assertTrue(driver.captures <= 30)
|
||||
assertEquals(0, driver.clicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `already visible order never scrolls and missing or conflicting payable amount never blocks order`() {
|
||||
val cases = listOf(
|
||||
emptyList<String>() to null,
|
||||
listOf("拼单价¥16", "平台优惠-¥3", "实付:13元") to null,
|
||||
listOf("应付:13元", "应付:14元") to null,
|
||||
listOf("应付:-13元", "应付:13.123元", "预计应付:13元") to null,
|
||||
listOf("应付:999999999999999999999元") to null,
|
||||
listOf("应付:13.20元", "应付:13.20元") to 1320L,
|
||||
listOf("应付:0元") to 0L,
|
||||
)
|
||||
for ((amounts, expected) in cases) {
|
||||
val driver = ReadOnlyOrderDriver(requiredSwipes = 0, amounts = amounts)
|
||||
val order = PurchaseLiveAutomation(driver, pause = {}).readOrderResult()
|
||||
assertEquals("PDD-DEMO-325", order?.orderNo)
|
||||
assertEquals(expected, order?.pddOrderAmountCent)
|
||||
assertEquals(0, driver.swipes)
|
||||
assertEquals(0, driver.clicks)
|
||||
}
|
||||
}
|
||||
|
||||
private class ReadOnlyOrderDriver(
|
||||
val paymentMs: Long = 0,
|
||||
val preludeSamples: Int = 0,
|
||||
val requiredSwipes: Int = 2,
|
||||
val amounts: List<String> = listOf("拼单价¥16", "平台优惠-¥3", "已优惠3元使用3元平台无门槛券,应付:,13元,(免运费)"),
|
||||
) : PurchaseUiDriver by LiveDriver() {
|
||||
var elapsed = 0L
|
||||
var captures = 0
|
||||
var backs = 0
|
||||
var swipes = 0
|
||||
var clicks = 0
|
||||
var firstSwipeAt: Long? = null
|
||||
override fun capture(): UiSnapshot {
|
||||
captures++
|
||||
if (captures <= preludeSamples) return UiSnapshot(PDD, ACTIVITY, emptyList())
|
||||
if (elapsed < paymentMs) return UiSnapshot(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf(node("立即支付")))
|
||||
return UiSnapshot(PDD, ACTIVITY, buildList {
|
||||
add(node("待付款"))
|
||||
// Amount scrolls away before the order fields become visible.
|
||||
if (swipes == 0) amounts.forEach { add(node(it)) }
|
||||
add(node("订单号:PDD-DEMO-325", swipes >= requiredSwipes))
|
||||
add(node("下单时间:2026-09-19 11:21:43", swipes >= requiredSwipes))
|
||||
add(node("去支付"))
|
||||
})
|
||||
}
|
||||
override fun backPurchase(): Boolean { backs++; return true }
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
assertEquals(SwipeDirection.UP, direction)
|
||||
assertTrue(elapsed >= paymentMs)
|
||||
if (firstSwipeAt == null) firstSwipeAt = elapsed
|
||||
swipes++
|
||||
return true
|
||||
}
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicks++
|
||||
throw AssertionError("Read-only unpaid branch must never click")
|
||||
}
|
||||
private fun node(text: String, visible: Boolean = true) = SnapshotNode(
|
||||
text, null, text, null, null, "android.widget.TextView",
|
||||
NodeBounds(0, 100, 500, if (visible) 180 else 100),
|
||||
false, false, false, false, true, visible,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verified spec panel advances through one exact confirm target`() {
|
||||
val driver = SpecConfirmationDriver()
|
||||
@@ -317,6 +415,35 @@ class PurchaseLiveAutomationTest {
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
// `[必须]` #302:卡在支付页放弃时,paymentPageObserved 必须为 true——这正是
|
||||
// 「大概率已经下单只是没读到证据」的信号,区分于真的什么都没发生的失败。
|
||||
@Test
|
||||
fun `payment repeated failure marks paymentPageObserved so it can be triaged later`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = List(4) { "payment" })
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg55")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg55"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_PAYMENT_REPEATED", automation.lastOrderReadFailure?.code)
|
||||
assertTrue(automation.lastOrderReadFailure?.paymentPageObserved ?: false)
|
||||
}
|
||||
|
||||
// 从未出现过支付/待付款文案的失败不该被标记,否则这个字段就没有区分价值了。
|
||||
@Test
|
||||
fun `a failure that never saw the payment page leaves paymentPageObserved false`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = List(15) { "empty" })
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg30")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg30"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_EMPTY_TIMEOUT", automation.lastOrderReadFailure?.code)
|
||||
assertFalse(automation.lastOrderReadFailure?.paymentPageObserved ?: true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuous payment activity still stops at bounded post back samples without payment clicks`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = List(4) { "payment" })
|
||||
@@ -329,10 +456,10 @@ class PurchaseLiveAutomationTest {
|
||||
assertEquals("PURCHASE_ORDER_PAYMENT_REPEATED", automation.lastOrderReadFailure?.code)
|
||||
assertEquals(
|
||||
"支付页安全返回后持续无订单证据,已停止自动核单" +
|
||||
"[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=3]",
|
||||
"[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=25]",
|
||||
automation.lastOrderReadFailure?.message,
|
||||
)
|
||||
assertEquals(4, driver.postSubmitCaptureCount)
|
||||
assertEquals(26, driver.postSubmitCaptureCount)
|
||||
assertEquals(1, driver.postSubmitBackCount)
|
||||
assertEquals(0, driver.genericSwipes)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
|
||||
@@ -781,7 +781,8 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true]",
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true;" +
|
||||
"addr=0;pay=0;qin=0;adj=0;summary=0;close=0;submit=0;same=0]",
|
||||
outcome.message,
|
||||
)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
|
||||
@@ -8,6 +8,16 @@ import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseResultPayloadTest {
|
||||
@Test
|
||||
fun `created order serializes optional payable total separately from unit price`() {
|
||||
val outcome = PurchaseExecutionOutcome("order_created", message = "test", actualUnitPriceCent = 1600, pddOrderAmountCent = 1300)
|
||||
val payload = JSONObject(purchaseResultPayload("r", "a", outcome))
|
||||
assertEquals(1300L, payload.getLong("pddOrderAmountCent"))
|
||||
assertEquals(1600L, payload.getLong("actualUnitPriceCent"))
|
||||
assertFalse(JSONObject(purchaseResultPayload("r", "a", outcome.copy(pddOrderAmountCent = null))).has("pddOrderAmountCent"))
|
||||
assertFalse(JSONObject(purchaseResultPayload("r", "a", outcome.copy(resultType = "order_result_unknown"))).has("pddOrderAmountCent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown order result keeps stable failure and verified price without order fields`() {
|
||||
val payload = JSONObject(purchaseResultPayload(
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseSpecProbePolicyTest {
|
||||
|
||||
@Test
|
||||
fun `zero dimensions is treated as empty`() {
|
||||
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty("""{"dimensions":[]}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dimensions with no values is treated as empty`() {
|
||||
assertTrue(
|
||||
PurchaseSpecProbePolicy.isDimensionsEmpty(
|
||||
"""{"dimensions":[{"key":"color","name":"颜色","values":[]},{"key":"size","name":"尺码","values":[]}]}""",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null blank or malformed payload is treated as empty`() {
|
||||
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty(null))
|
||||
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty(""))
|
||||
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty("not json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dimensions with at least one value is not empty`() {
|
||||
assertFalse(
|
||||
PurchaseSpecProbePolicy.isDimensionsEmpty(
|
||||
"""{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote replaces an empty spec_probe_completed outcome with an explicit failure`() {
|
||||
val outcome = PurchaseExecutionOutcome(
|
||||
"spec_probe_completed",
|
||||
message = "商品规格已回传,等待服务端匹配",
|
||||
probedSpecs = """{"dimensions":[]}""",
|
||||
)
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals("failed", demoted.resultType)
|
||||
assertEquals("PURCHASE_SPEC_PROBE_EMPTY", demoted.errorCode)
|
||||
assertEquals("规格探测未读取到任何颜色或尺码", demoted.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote leaves a non-empty probe result unchanged`() {
|
||||
val outcome = PurchaseExecutionOutcome(
|
||||
"spec_probe_completed",
|
||||
message = "商品规格已回传,等待服务端匹配",
|
||||
probedSpecs = """{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
|
||||
)
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals(outcome, demoted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote leaves other outcome types unchanged`() {
|
||||
val outcome = PurchaseExecutionOutcome("failed", "PURCHASE_SPEC_NOT_MATCHED", "商品规格探测失败")
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals(outcome, demoted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractCollectorFailureCode reads the encoded collector failure code`() {
|
||||
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
|
||||
|
||||
assertEquals("SPEC_PANEL_TOP_COLLAPSED", PurchaseSpecProbePolicy.extractCollectorFailureCode(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractCollectorFailureCode returns null for a normal probe payload`() {
|
||||
assertEquals(
|
||||
null,
|
||||
PurchaseSpecProbePolicy.extractCollectorFailureCode(
|
||||
"""{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
|
||||
),
|
||||
)
|
||||
assertEquals(null, PurchaseSpecProbePolicy.extractCollectorFailureCode(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote surfaces an encoded SPEC_PANEL_TOP_COLLAPSED collector failure with a purchaser-readable message`() {
|
||||
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
|
||||
val outcome = PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = payload)
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals("failed", demoted.resultType)
|
||||
assertEquals("PURCHASE_SPEC_PROBE_FAILED", demoted.errorCode)
|
||||
assertEquals("规格探测时规格面板被拖动,规格标题消失", demoted.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote falls back to a generic message for an unrecognized collector failure code`() {
|
||||
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
val outcome = PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = payload)
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals("failed", demoted.resultType)
|
||||
assertEquals("PURCHASE_SPEC_PROBE_FAILED", demoted.errorCode)
|
||||
assertTrue(demoted.message.contains("RULE_NOT_MATCHED"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** #332 spec-option dedup (task 535) and the selection-failure diagnostic. */
|
||||
class SpecOptionDedupTest {
|
||||
private fun parse(snapshot: UiSnapshot) =
|
||||
PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "719834019024", null)
|
||||
|
||||
@Test
|
||||
fun `nested clickable nodes of one option block form one value`() {
|
||||
val screen = parse(SpecPanelFixtures.taskOptionDedupSheet())
|
||||
val colors = screen.dimensions.single { it.key == "color" }.values
|
||||
val sizes = screen.dimensions.single { it.key == "size" }.values
|
||||
|
||||
assertEquals(listOf("兰条纹", "白条纹"), colors.map { it.text })
|
||||
assertTrue(colors.single { it.text == "兰条纹" }.node.selected)
|
||||
assertFalse(colors.single { it.text == "白条纹" }.node.selected)
|
||||
assertEquals(5, sizes.size)
|
||||
assertEquals(listOf("2XL建议130-150斤"), sizes.filter { it.node.selected }.map { it.rawText })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unconfirmed selection reports parsed values selected flags and target`() {
|
||||
// The click never changes the page, so the white option stays unselected.
|
||||
val driver = object : PurchaseUiDriver {
|
||||
var opened = false
|
||||
override fun capture(): UiSnapshot =
|
||||
if (opened) SpecPanelFixtures.taskOptionDedupSheet() else SpecPanelFixtures.productDetailPage()
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
if (target.label.startsWith("请选择")) opened = true
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
|
||||
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun backPurchase() = false
|
||||
}
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
|
||||
val evidence = diagnostics.last { it.startsWith("specSelectionUnconfirmed;") }
|
||||
assertTrue(evidence, evidence.contains("dimension=color;target=白条纹;valueCount=2;values=兰条纹:t|白条纹:f"))
|
||||
assertFalse(evidence.contains("****"))
|
||||
}
|
||||
|
||||
private fun input() = PurchaseExecutionInput(
|
||||
taskId = 332,
|
||||
executionMode = "rehearsal",
|
||||
phase = "purchase",
|
||||
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
|
||||
goodsId = "719834019024",
|
||||
mappedColor = "白条纹",
|
||||
mappedSize = "",
|
||||
quantity = 1,
|
||||
minUnitPriceCent = 1_000,
|
||||
maxUnitPriceCent = 3_000,
|
||||
)
|
||||
|
||||
private fun rule() = """{
|
||||
"schemaVersion":1,
|
||||
"ruleType":"pddPurchase",
|
||||
"requiredCapabilities":["purchase.rehearsal.v1"],
|
||||
"actions":[
|
||||
{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
|
||||
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}
|
||||
]
|
||||
}"""
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.NodeBounds
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||
|
||||
/**
|
||||
* Sanitized spec-panel fixtures (#332 option dedup, #331 recognition). The layout mirrors the structure of real
|
||||
* PDD purchase sheets, but every name, phone number and address is fake.
|
||||
*/
|
||||
internal object SpecPanelFixtures {
|
||||
const val PDD = "com.xunmeng.pinduoduo"
|
||||
const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"
|
||||
const val FAKE_PHONE = "139****0000"
|
||||
|
||||
class Tree(private val screenBottom: Int) {
|
||||
val nodes = mutableListOf<SnapshotNode>()
|
||||
|
||||
init {
|
||||
add("r", "", NodeBounds(0, 0, 1080, screenBottom), className = "android.widget.FrameLayout")
|
||||
}
|
||||
|
||||
fun add(
|
||||
path: String,
|
||||
text: String,
|
||||
bounds: NodeBounds,
|
||||
className: String = "android.widget.TextView",
|
||||
clickable: Boolean = false,
|
||||
scrollable: Boolean = false,
|
||||
selected: Boolean = false,
|
||||
visible: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
description: String? = null,
|
||||
) {
|
||||
val parent = path.substringBeforeLast('/', "").ifEmpty { null }
|
||||
nodes += SnapshotNode(
|
||||
path, parent, text, description, null, className, bounds,
|
||||
clickable, scrollable, selected, false, enabled, visible,
|
||||
)
|
||||
}
|
||||
|
||||
fun snapshot(packageName: String = PDD, activity: String = ACTIVITY) = UiSnapshot(packageName, activity, nodes.toList())
|
||||
}
|
||||
|
||||
/** Options for the purchase sheet shaped like the two unselected real samples. */
|
||||
data class Sheet(
|
||||
val screenBottom: Int = 2216,
|
||||
val address: Boolean = true,
|
||||
val payment: Boolean = true,
|
||||
val quantityInput: Boolean = true,
|
||||
val adjustButtons: Boolean = true,
|
||||
val close: Boolean = true,
|
||||
val summary: Boolean = true,
|
||||
val sizeDimension: Boolean = true,
|
||||
val listScrollable: Boolean = true,
|
||||
/** "visible" = normal submit bar; "hidden" = Samsung-like 31px bar whose [0,0][0,0] label is not visible; "none". */
|
||||
val submit: String = "visible",
|
||||
val offset: Int = 0,
|
||||
val colorLabels: List<String> = listOf("黑色 示例款", "卡其色 示例款"),
|
||||
val sizeLabels: List<String> = listOf("S 建议75-90斤", "M 建议90-105斤", "L 建议105-115斤"),
|
||||
)
|
||||
|
||||
fun sheet(options: Sheet = Sheet()): UiSnapshot {
|
||||
val o = options.offset
|
||||
val t = Tree(options.screenBottom)
|
||||
t.add("r/sheet", "", NodeBounds(0, 84, 1080, options.screenBottom), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("r/sheet/top", "", NodeBounds(0, 219, 1080, 330), "android.widget.ViewSwitcher", clickable = true)
|
||||
t.add("r/sheet/top/tags", "#示例标签#七天退换", NodeBounds(193, 240, 886, 308))
|
||||
if (options.close) {
|
||||
t.add("r/sheet/close", "", NodeBounds(975, 237, 1050, 312), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||
}
|
||||
t.add("r/sheet/body", "", NodeBounds(0, 330, 1080, options.screenBottom), "android.view.ViewGroup", clickable = true)
|
||||
if (options.address) {
|
||||
// Address row: clickable row five levels above the masked phone text.
|
||||
t.add("r/sheet/body/addr", "", NodeBounds(0, 330 + o, 1080, 484 + o), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/body/addr/a", "", NodeBounds(0, 340 + o, 1080, 470 + o), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/addr/a/b", "", NodeBounds(36, 340 + o, 1044, 400 + o), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/addr/a/b/c", "", NodeBounds(36, 340 + o, 1044, 400 + o), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/addr/a/b/c/tag", "7天无理由退货", NodeBounds(138, 348 + o, 394, 394 + o))
|
||||
t.add("r/sheet/body/addr/a/b/c/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(412, 346 + o, 993, 395 + o))
|
||||
t.add("r/sheet/body/addr/a/detail", "示例区示例街道示例路1号", NodeBounds(132, 408 + o, 937, 457 + o))
|
||||
}
|
||||
t.add("r/sheet/body/image", "", NodeBounds(36, 638, 360, 962), "android.widget.FrameLayout", clickable = true, description = "商品主图")
|
||||
t.add("r/sheet/body/price", "¥19.6", NodeBounds(384, 640, 523, 707))
|
||||
if (options.summary) t.add("r/sheet/body/summary", "请选择: 颜色分类 尺码", NodeBounds(384, 791, 1068, 852))
|
||||
t.add("r/sheet/body/qty", "", NodeBounds(384, 888, 633, 963), "android.widget.LinearLayout")
|
||||
if (options.adjustButtons) {
|
||||
t.add("r/sheet/body/qty/dec", "", NodeBounds(384, 888, 462, 963), "android.widget.ImageView", clickable = true, description = "减少数量")
|
||||
t.add("r/sheet/body/qty/inc", "", NodeBounds(555, 888, 633, 963), "android.widget.ImageView", clickable = true, description = "增加数量")
|
||||
}
|
||||
if (options.quantityInput) {
|
||||
t.add("r/sheet/body/qty/input", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
|
||||
}
|
||||
val list = "r/sheet/body/list"
|
||||
t.add(list, "", NodeBounds(0, 1136, 1080, 2036), "androidx.recyclerview.widget.RecyclerView", scrollable = options.listScrollable)
|
||||
t.add("$list/color", "", NodeBounds(0, 1136, 1080, 1686), "android.widget.LinearLayout")
|
||||
t.add("$list/color/h", "颜色分类", NodeBounds(36, 1161, 216, 1222))
|
||||
options.colorLabels.forEachIndexed { index, label ->
|
||||
val step = if (options.colorLabels.size > 3) 258 else 346
|
||||
val w = if (options.colorLabels.size > 3) 240 else 316
|
||||
val left = 36 + index * step
|
||||
val block = "$list/color/o$index"
|
||||
t.add(block, "", NodeBounds(left, 1247, left + w, 1650), "android.view.ViewGroup", clickable = true, description = label)
|
||||
t.add("$block/img", "", NodeBounds(left, 1247, left + w, 1563), "android.widget.ImageView", clickable = true, description = label)
|
||||
t.add("$block/big", "", NodeBounds(left, 1247, left + 111, 1358), "android.widget.ImageView", clickable = true, description = "打开大图")
|
||||
t.add("$block/l", "", NodeBounds(left, 1563, left + w, 1650), "android.widget.LinearLayout")
|
||||
t.add("$block/l/t", label, NodeBounds(left, 1563, left + w, 1650), clickable = true)
|
||||
}
|
||||
if (options.sizeDimension) {
|
||||
t.add("$list/size", "", NodeBounds(0, 1686, 1080, 2036), "android.widget.LinearLayout")
|
||||
t.add("$list/size/row", "", NodeBounds(36, 1686, 1044, 1767), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("$list/size/row/h", "尺码", NodeBounds(36, 1700, 126, 1753))
|
||||
t.add("$list/size/row/hint", "查看尺码建议", NodeBounds(150, 1702, 402, 1751))
|
||||
options.sizeLabels.forEachIndexed { index, label ->
|
||||
val top = 1779 + (index / 2) * 85
|
||||
val left = 36 + (index % 2) * 420
|
||||
t.add("$list/size/o$index", "", NodeBounds(left, top, left + 364, top + 80), "android.view.ViewGroup", clickable = true)
|
||||
t.add("$list/size/o$index/t", label, NodeBounds(left, top, left + 364, top + 80), clickable = true)
|
||||
}
|
||||
}
|
||||
if (options.payment) {
|
||||
t.add("r/sheet/pay", "", NodeBounds(0, 2036, 1080, 2135), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/pay/l", "", NodeBounds(94, 2057, 930, 2114), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/pay/l/t", "使用#微信支付,更换先用后付可0元下单", NodeBounds(112, 2057, 930, 2114))
|
||||
}
|
||||
when (options.submit) {
|
||||
"visible" -> {
|
||||
t.add("r/sheet/submit", "", NodeBounds(0, 2135, 1080, options.screenBottom), "android.widget.FrameLayout", clickable = true)
|
||||
t.add("r/sheet/submit/l", "", NodeBounds(145, 2135, 934, options.screenBottom), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/submit/l/t", "选择颜色分类及尺码后,提交订单", NodeBounds(157, 2179, 922, options.screenBottom))
|
||||
}
|
||||
"hidden" -> {
|
||||
t.add("r/sheet/submit", "", NodeBounds(0, options.screenBottom - 31, 1080, options.screenBottom), "android.widget.FrameLayout", clickable = true)
|
||||
t.add("r/sheet/submit/l", "", NodeBounds(0, 0, 0, 0), "android.widget.LinearLayout", visible = false)
|
||||
t.add("r/sheet/submit/l/t", "选择颜色分类及尺码后,提交订单", NodeBounds(0, 0, 0, 0), visible = false)
|
||||
}
|
||||
}
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal fallback sheet: no bounded scrollable, a single heading, no
|
||||
* summary, no +/- and no readable submit. Only the #331 required-evidence
|
||||
* fallback can recognize it. (Not the goods 8580 shape; see [sample8580ShapedSheet].)
|
||||
*/
|
||||
fun liveShapedSheet(options: Sheet = Sheet()): UiSnapshot = sheet(
|
||||
options.copy(summary = false, listScrollable = false, sizeDimension = false, adjustButtons = false, submit = "hidden"),
|
||||
)
|
||||
|
||||
/**
|
||||
* Shape of the 13 goods 8580 failures reported in #331:
|
||||
* `type=UNKNOWN;scrollables=1;headings=2;options=9;summary=false;quantity=true;orderAction=false;pageEvidenceMatched=true`.
|
||||
* One bounded scrollable, two headings, nine options, +/- and input present,
|
||||
* but no selection summary and no readable submit action.
|
||||
*/
|
||||
fun sample8580ShapedSheet(options: Sheet = Sheet()): UiSnapshot = sheet(
|
||||
options.copy(
|
||||
summary = false,
|
||||
submit = "hidden",
|
||||
colorLabels = listOf("黑色 示例款", "卡其色 示例款", "白色 示例款", "灰色 示例款"),
|
||||
sizeLabels = listOf("S 示例", "M 示例", "L 示例", "XL 示例", "2XL 示例"),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* #331 counter-example: a masked-phone row inside an address dialog, a
|
||||
* payment row inside a separate payment dialog and a quantity input in a
|
||||
* third place, together with a parsed option group. Every item exists but
|
||||
* they do not share one panel container.
|
||||
*/
|
||||
fun scatteredRequiredItemsPage(fullScreenWrapper: Boolean = false): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
val root = if (fullScreenWrapper) {
|
||||
t.add("r/content", "", NodeBounds(0, 0, 1080, 2216), "android.widget.FrameLayout")
|
||||
"r/content"
|
||||
} else "r"
|
||||
t.add("$root/addrDialog", "", NodeBounds(60, 200, 1020, 700), "android.widget.FrameLayout")
|
||||
t.add("$root/addrDialog/title", "选择收货地址", NodeBounds(360, 220, 720, 290))
|
||||
t.add("$root/addrDialog/row", "", NodeBounds(60, 320, 1020, 470), "android.view.ViewGroup", clickable = true)
|
||||
t.add("$root/addrDialog/row/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(100, 340, 980, 400))
|
||||
t.add("$root/payDialog", "", NodeBounds(60, 800, 1020, 1200), "android.widget.FrameLayout")
|
||||
t.add("$root/payDialog/row", "", NodeBounds(60, 900, 1020, 1010), "android.view.ViewGroup", clickable = true)
|
||||
t.add("$root/payDialog/row/t", "微信支付", NodeBounds(120, 920, 400, 990))
|
||||
t.add("$root/other", "", NodeBounds(0, 1250, 1080, 2216), "android.widget.LinearLayout")
|
||||
t.add("$root/other/close", "", NodeBounds(975, 1260, 1050, 1335), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||
t.add("$root/other/input", "1", NodeBounds(468, 1350, 549, 1425), "android.widget.EditText", clickable = true)
|
||||
t.add("$root/other/h", "颜色分类", NodeBounds(36, 1500, 216, 1560))
|
||||
t.add("$root/other/o0", "黑色 示例款", NodeBounds(36, 1600, 352, 1700), clickable = true)
|
||||
t.add("$root/other/o1", "白色 示例款", NodeBounds(382, 1600, 698, 1700), clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
/** Task 535 layout: the selected option block exposes an outer block, an image and an inner text with a badge. */
|
||||
fun taskOptionDedupSheet(): UiSnapshot {
|
||||
val t = Tree(2328)
|
||||
t.add("r/sheet", "", NodeBounds(0, 120, 1080, 2328), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("r/sheet/close", "", NodeBounds(975, 273, 1050, 348), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||
t.add("r/sheet/body", "", NodeBounds(0, 366, 1080, 2328), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/body/addr", "", NodeBounds(0, 366, 1080, 520), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/body/addr/a", "", NodeBounds(0, 376, 1080, 510), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/addr/a/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(412, 382, 993, 431))
|
||||
t.add("r/sheet/body/addr/a/detail", "示例区示例路2号", NodeBounds(132, 444, 993, 493))
|
||||
t.add("r/sheet/body/price", "快抢光 ¥23.99", NodeBounds(396, 568, 722, 635))
|
||||
t.add("r/sheet/body/selected", "#快抢光#兰条纹 2XL建议130-150斤", NodeBounds(396, 719, 1053, 781))
|
||||
t.add("r/sheet/body/qty", "", NodeBounds(396, 817, 614, 895), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/qty/dec", "", NodeBounds(396, 817, 462, 895), "android.widget.ImageView", clickable = true, description = "减少数量")
|
||||
t.add("r/sheet/body/qty/input", "1", NodeBounds(463, 817, 547, 895), "android.widget.EditText", clickable = true)
|
||||
t.add("r/sheet/body/qty/inc", "", NodeBounds(548, 817, 614, 895), "android.widget.ImageView", clickable = true, description = "增加数量")
|
||||
val list = "r/sheet/body/list"
|
||||
t.add(list, "", NodeBounds(0, 933, 1080, 2079), "androidx.recyclerview.widget.RecyclerView", scrollable = true)
|
||||
t.add("$list/color", "", NodeBounds(0, 933, 1080, 1483), "android.widget.LinearLayout")
|
||||
t.add("$list/color/h", "颜色分类", NodeBounds(36, 958, 216, 1019))
|
||||
val blue = "$list/color/o0"
|
||||
t.add(blue, "", NodeBounds(36, 1044, 352, 1447), "android.view.ViewGroup", clickable = true, selected = true, description = " 兰条纹")
|
||||
t.add("$blue/img", "", NodeBounds(36, 1044, 352, 1360), "android.widget.ImageView", clickable = true, selected = true, description = " 兰条纹")
|
||||
t.add("$blue/big", "", NodeBounds(36, 1044, 147, 1155), "android.widget.ImageView", clickable = true, selected = true, description = "打开大图")
|
||||
t.add("$blue/l", "", NodeBounds(36, 1339, 352, 1447), "android.widget.LinearLayout", selected = true)
|
||||
t.add("$blue/l/t", " 兰条纹\n 零差评", NodeBounds(36, 1339, 352, 1447), clickable = true, selected = true)
|
||||
val white = "$list/color/o1"
|
||||
t.add(white, "", NodeBounds(382, 1044, 698, 1447), "android.view.ViewGroup", clickable = true, description = "白条纹")
|
||||
t.add("$white/img", "", NodeBounds(382, 1044, 698, 1360), "android.widget.ImageView", clickable = true, description = "白条纹")
|
||||
t.add("$white/big", "", NodeBounds(382, 1044, 493, 1155), "android.widget.ImageView", clickable = true, description = "打开大图")
|
||||
t.add("$white/badge", "快要抢光", NodeBounds(521, 1044, 698, 1093))
|
||||
t.add("$white/l", "", NodeBounds(382, 1339, 698, 1447), "android.widget.LinearLayout")
|
||||
t.add("$white/l/t", "白条纹", NodeBounds(382, 1339, 698, 1447), clickable = true)
|
||||
t.add("$list/size", "", NodeBounds(0, 1483, 1080, 1938), "android.widget.LinearLayout")
|
||||
t.add("$list/size/row", "", NodeBounds(36, 1483, 1044, 1564), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("$list/size/row/h", "尺码", NodeBounds(36, 1497, 126, 1550))
|
||||
listOf(
|
||||
Triple("L建议100-115斤", NodeBounds(36, 1576, 371, 1661), false),
|
||||
Triple("XL建议115-130斤", NodeBounds(401, 1576, 762, 1661), false),
|
||||
Triple("2XL建议130-150斤", NodeBounds(36, 1691, 430, 1776), true),
|
||||
Triple("3XL建议150-170斤", NodeBounds(460, 1691, 853, 1776), false),
|
||||
Triple("4XL建议170-190斤", NodeBounds(36, 1806, 431, 1891), false),
|
||||
).forEachIndexed { index, (label, bounds, selected) ->
|
||||
t.add("$list/size/o$index", "", bounds, "android.view.ViewGroup", clickable = true)
|
||||
t.add("$list/size/o$index/t", label, bounds, clickable = true, selected = selected)
|
||||
}
|
||||
t.add("r/sheet/pay", "", NodeBounds(0, 2079, 1080, 2178), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/pay/t", "使用#微信支付,更换先用后付可0元下单", NodeBounds(112, 2100, 930, 2157))
|
||||
t.add("r/sheet/submit", "", NodeBounds(0, 2181, 1080, 2328), "android.widget.FrameLayout", clickable = true)
|
||||
t.add("r/sheet/submit/t", "提交订单 ¥23.99", NodeBounds(365, 2225, 715, 2284))
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun productDetailPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/pager", "示例商品标题连衣裙夏季新款示例", NodeBounds(0, 84, 1080, 1100), "androidx.viewpager.widget.ViewPager")
|
||||
t.add("r/price", "¥19.6", NodeBounds(36, 1120, 300, 1200))
|
||||
t.add("r/sales", "已拼1万+件", NodeBounds(700, 1120, 1044, 1200))
|
||||
t.add("r/deliver", "配送至 示例省示例市", NodeBounds(36, 1300, 1044, 1360))
|
||||
t.add("r/spec", "请选择 颜色分类 尺码", NodeBounds(36, 1400, 1044, 1480), clickable = true)
|
||||
t.add("r/review", "商品评价(2000+)", NodeBounds(36, 1500, 1044, 1580), clickable = true)
|
||||
t.add("r/single", "单独购买", NodeBounds(420, 2080, 720, 2200), clickable = true)
|
||||
t.add("r/group", "发起拼单", NodeBounds(740, 2080, 1060, 2200), clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun reviewPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/title", "商品评价", NodeBounds(400, 120, 680, 200))
|
||||
t.add("r/all", "全部", NodeBounds(36, 260, 150, 320), clickable = true)
|
||||
t.add("r/media", "有图/视频", NodeBounds(170, 260, 360, 320), clickable = true)
|
||||
t.add("r/body", "质量不错,微信支付很方便", NodeBounds(36, 400, 1044, 500))
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun addressListPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/title", "收货地址", NodeBounds(400, 120, 680, 200))
|
||||
listOf(0, 1).forEach { index ->
|
||||
val top = 300 + index * 260
|
||||
t.add("r/card$index", "", NodeBounds(0, top, 1080, top + 240), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/card$index/phone", "测试 $FAKE_PHONE", NodeBounds(36, top + 20, 700, top + 80))
|
||||
t.add("r/card$index/detail", "示例省示例市示例路${index + 1}号", NodeBounds(36, top + 100, 900, top + 160))
|
||||
t.add("r/card$index/edit", "修改", NodeBounds(950, top + 20, 1044, top + 80), clickable = true)
|
||||
}
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun addressEditPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/title", "修改收货地址", NodeBounds(360, 120, 720, 200))
|
||||
t.add("r/name", "测试收货人", NodeBounds(180, 190, 900, 270), "android.widget.EditText", clickable = true)
|
||||
t.add("r/phone", "13900000000", NodeBounds(180, 290, 900, 370), "android.widget.EditText", clickable = true)
|
||||
t.add("r/detailLabel", "详细地址", NodeBounds(20, 400, 160, 460))
|
||||
t.add("r/detail", "示例路1号", NodeBounds(180, 390, 900, 480), "android.widget.EditText", clickable = true)
|
||||
t.add("r/save", "保存", NodeBounds(36, 2080, 1044, 2180), clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun paymentMethodDialog(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/title", "选择支付方式", NodeBounds(360, 1200, 720, 1280))
|
||||
t.add("r/wechat", "", NodeBounds(0, 1300, 1080, 1420), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/wechat/t", "微信支付", NodeBounds(120, 1330, 400, 1390))
|
||||
t.add("r/later", "", NodeBounds(0, 1440, 1080, 1560), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/later/t", "先用后付", NodeBounds(120, 1470, 400, 1530))
|
||||
t.add("r/ok", "确定", NodeBounds(36, 2080, 1044, 2180), clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun postSubmitPaymentPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/amount", "¥19.60", NodeBounds(360, 300, 720, 400))
|
||||
t.add("r/wechat", "", NodeBounds(0, 600, 1080, 720), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/wechat/t", "微信支付", NodeBounds(120, 630, 400, 690))
|
||||
t.add("r/pay", "立即支付", NodeBounds(36, 2080, 1044, 2180), clickable = true)
|
||||
return t.snapshot(activity = "com.xunmeng.pinduoduo.app_pay.core.PayActivity")
|
||||
}
|
||||
|
||||
fun quantityOnlyPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/input", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.SpecPanelFixtures.Sheet
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.NodeBounds
|
||||
import cn.ilapage.goauto.agent.automation.ParsedPddScreen
|
||||
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseLiveException
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.SpecPanelStabilityPolicy
|
||||
import cn.ilapage.goauto.agent.automation.SpecPanelType
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** #331 spec-panel recognition, same-container check, hints, bounded waits and strict submit targets. */
|
||||
class SpecPanelRecognitionTest {
|
||||
private fun parse(snapshot: UiSnapshot) =
|
||||
PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "719834019024", null)
|
||||
|
||||
// --- Recognition -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `both sanitized unselected sheet samples are recognized as spec panels`() {
|
||||
val first = parse(SpecPanelFixtures.sheet())
|
||||
val samsung = parse(SpecPanelFixtures.sheet(Sheet(screenBottom = 2020, submit = "hidden")))
|
||||
|
||||
listOf(first, samsung).forEach { screen ->
|
||||
assertTrue(screen.specPanelOpen)
|
||||
assertTrue(screen.hasAddressEntry)
|
||||
assertTrue(screen.hasPaymentEntry)
|
||||
assertTrue(screen.hasQuantityInput)
|
||||
}
|
||||
// The zero-size submit label is not visible, so the submit action is absent.
|
||||
assertFalse(samsung.hasOrderSubmitAction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live shaped samples are recognized only through the required evidence fallback`() {
|
||||
listOf(2216, 2020).forEach { bottom ->
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = bottom)))
|
||||
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
assertTrue(screen.specPanelOpen)
|
||||
assertEquals(0, screen.panelScrollableCount)
|
||||
assertFalse(screen.hasSelectionSummary)
|
||||
assertFalse(screen.hasQuantityControls)
|
||||
assertFalse(screen.hasQuantityAdjustControls)
|
||||
assertFalse(screen.hasOrderSubmitAction)
|
||||
assertEquals(listOf("黑色 示例款", "卡其色 示例款"), screen.dimensions.single().values.map { it.text })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero size submit label sample is still recognized because submit is auxiliary`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2020)))
|
||||
|
||||
assertFalse(screen.hasOrderSubmitAction)
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing address entry is not recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(address = false)))
|
||||
assertFalse(screen.hasAddressEntry)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing payment entry is not recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(payment = false)))
|
||||
assertFalse(screen.hasPaymentEntry)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing quantity input is not recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(quantityInput = false)))
|
||||
assertFalse(screen.hasQuantityInput)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `required items without any auxiliary evidence are not recognized`() {
|
||||
val t = SpecPanelFixtures.Tree(2216)
|
||||
t.add("r/panel", "", NodeBounds(0, 300, 1080, 2216), "android.widget.LinearLayout")
|
||||
t.add("r/panel/addr", "", NodeBounds(0, 330, 1080, 484), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/panel/addr/phone", "测试,${SpecPanelFixtures.FAKE_PHONE}", NodeBounds(412, 346, 993, 395))
|
||||
t.add("r/panel/qty", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
|
||||
t.add("r/panel/pay", "", NodeBounds(0, 2036, 1080, 2135), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/panel/pay/t", "使用微信支付", NodeBounds(112, 2057, 930, 2114))
|
||||
|
||||
val screen = parse(t.snapshot())
|
||||
|
||||
assertTrue(screen.hasRequiredPanelEvidence)
|
||||
assertTrue(screen.requiredEvidenceSameContainer)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quantity input alone is not recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.quantityOnlyPage())
|
||||
assertTrue(screen.hasQuantityInput)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
assertFalse(screen.specPanelOpen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non panel pages are never recognized as spec panels`() {
|
||||
mapOf(
|
||||
"product" to SpecPanelFixtures.productDetailPage(),
|
||||
"review" to SpecPanelFixtures.reviewPage(),
|
||||
"addressList" to SpecPanelFixtures.addressListPage(),
|
||||
"addressEdit" to SpecPanelFixtures.addressEditPage(),
|
||||
"paymentDialog" to SpecPanelFixtures.paymentMethodDialog(),
|
||||
"paymentPage" to SpecPanelFixtures.postSubmitPaymentPage(),
|
||||
).forEach { (name, snapshot) ->
|
||||
val screen = parse(snapshot)
|
||||
assertEquals(name, SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
assertFalse(name, screen.specPanelOpen)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `whole sheet clickable containers do not count as an address row`() {
|
||||
val t = SpecPanelFixtures.Tree(2216)
|
||||
t.add("r/sheet", "", NodeBounds(0, 84, 1080, 2216), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("r/sheet/phone", "测试,${SpecPanelFixtures.FAKE_PHONE}", NodeBounds(412, 346, 993, 395))
|
||||
assertFalse(parse(t.snapshot()).hasAddressEntry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single dimension sheet without adjust buttons is recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.sheet(Sheet(sizeDimension = false, adjustButtons = false, summary = false, listScrollable = false, submit = "none")))
|
||||
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
assertEquals(listOf("color"), screen.dimensions.map { it.key })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `required evidence diagnostics hold booleans and counts only`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet())
|
||||
val summary = screen.requiredEvidenceSummary()
|
||||
|
||||
assertEquals("addr=1;pay=1;qin=1;adj=0;summary=0;close=1;submit=0;same=1", summary)
|
||||
assertFalse(summary.contains("****"))
|
||||
assertFalse(summary.contains("示例"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `goods 8580 shaped sheet is recognized through the required evidence fallback`() {
|
||||
val screen = parse(SpecPanelFixtures.sample8580ShapedSheet())
|
||||
|
||||
// Shape of the 13 live failures: scrollables=1;headings=2;options=9;
|
||||
// summary=false;quantity=true;orderAction=false (was UNKNOWN before #331).
|
||||
assertEquals(1, screen.panelScrollableCount)
|
||||
assertEquals(2, screen.panelHeadingCount)
|
||||
assertEquals(9, screen.panelOptionCount)
|
||||
assertFalse(screen.hasSelectionSummary)
|
||||
assertTrue(screen.hasQuantityControls)
|
||||
assertFalse(screen.hasOrderSubmitAction)
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
assertTrue(screen.requiredEvidenceSameContainer)
|
||||
}
|
||||
|
||||
// --- Same panel container --------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `required items inside one panel container are recognized`() {
|
||||
listOf(SpecPanelFixtures.liveShapedSheet(), SpecPanelFixtures.sample8580ShapedSheet()).forEach { snapshot ->
|
||||
val screen = parse(snapshot)
|
||||
assertTrue(screen.requiredEvidenceSameContainer)
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address dialog payment dialog and a separate quantity input are not a spec panel`() {
|
||||
listOf(false, true).forEach { wrapper ->
|
||||
val screen = parse(SpecPanelFixtures.scatteredRequiredItemsPage(fullScreenWrapper = wrapper))
|
||||
|
||||
// Every required item exists somewhere on screen, plus auxiliary
|
||||
// options and a close control, but not inside one panel container.
|
||||
assertTrue(screen.hasAddressEntry)
|
||||
assertTrue(screen.hasPaymentEntry)
|
||||
assertTrue(screen.hasQuantityInput)
|
||||
assertTrue(screen.dimensions.any { it.values.isNotEmpty() })
|
||||
assertFalse(screen.requiredEvidenceSameContainer)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
assertFalse(screen.specPanelOpen)
|
||||
assertEquals(listOf(ParsedPddScreen.NOT_SAME_CONTAINER_HINT), screen.requiredEvidenceHints())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Purchaser hints for missing required items --------------------------
|
||||
|
||||
@Test
|
||||
fun `missing required items produce purchaser hints`() {
|
||||
val noPay = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(payment = false)))
|
||||
val noAddr = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(address = false)))
|
||||
val noQty = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(quantityInput = false)))
|
||||
|
||||
assertEquals(listOf("未找到支付入口:请确认 PDD 默认支付方式为微信支付"), noPay.requiredEvidenceHints())
|
||||
assertEquals(listOf("未找到收货地址入口:请确认 PDD 已设置默认收货地址"), noAddr.requiredEvidenceHints())
|
||||
assertEquals(listOf("未找到购买数量输入框"), noQty.requiredEvidenceHints())
|
||||
assertTrue(parse(SpecPanelFixtures.sample8580ShapedSheet()).requiredEvidenceHints().isEmpty())
|
||||
// Unrelated pages without any required item or option do not get environment hints.
|
||||
assertTrue(parse(SpecPanelFixtures.reviewPage()).requiredEvidenceHints().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec panel evidence failure message carries the missing payment hint`() {
|
||||
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sample8580ShapedSheet(Sheet(payment = false))))
|
||||
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertTrue(outcome.message.orEmpty().contains("未找到支付入口:请确认 PDD 默认支付方式为微信支付"))
|
||||
assertFalse(outcome.message.orEmpty().contains("****"))
|
||||
assertEquals(0, driver.backs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec panel evidence failure message carries the missing address and quantity hints`() {
|
||||
mapOf(
|
||||
Sheet(address = false) to "未找到收货地址入口:请确认 PDD 已设置默认收货地址",
|
||||
Sheet(quantityInput = false) to "未找到购买数量输入框",
|
||||
).forEach { (options, hint) ->
|
||||
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sample8580ShapedSheet(options)))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertTrue(outcome.message.orEmpty(), outcome.message.orEmpty().contains(hint))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address save timeout message carries the missing address hint`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val driver = AddressSaveDriver(listOf(loading)) { loading }
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg35") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertTrue(error.message.orEmpty().contains("未找到收货地址入口:请确认 PDD 已设置默认收货地址"))
|
||||
}
|
||||
|
||||
// --- Bounded wait after the first spec-entry click -----------------------
|
||||
|
||||
@Test
|
||||
fun `loading sheet is accepted after required evidence becomes complete and stable without back`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val ready = SpecPanelFixtures.liveShapedSheet()
|
||||
val driver = SpecEntryDriver(listOf(loading, loading, loading, ready, ready))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val steps = mutableListOf<String>()
|
||||
|
||||
PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, stepChanged = steps::add, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertTrue(steps.contains("selectSpec"))
|
||||
assertEquals(0, driver.backs)
|
||||
assertTrue(diagnostics.any { it.startsWith("specPanelWait;outcome=opened;") && it.contains("backPressed=false") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `always incomplete sheet fails explicitly at the wait bound without back`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val driver = SpecEntryDriver(listOf(loading))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(0, driver.backs)
|
||||
val timeout = diagnostics.single { it.startsWith("specPanelWait;outcome=timeout;") }
|
||||
assertTrue(timeout.contains("waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fallback sheet whose structure keeps changing is never accepted early`() {
|
||||
val first = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2216))
|
||||
val second = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2200))
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, parse(second).specPanelType)
|
||||
val driver = SpecEntryDriver(listOf(first, second), cycle = true)
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(0, driver.backs)
|
||||
assertFalse(diagnostics.any { it.startsWith("specPanelWait;outcome=opened") })
|
||||
assertTrue(diagnostics.any { it.contains("waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stronger panel type is still accepted immediately`() {
|
||||
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sheet()))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val steps = mutableListOf<String>()
|
||||
|
||||
PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, stepChanged = steps::add, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertTrue(steps.contains("selectSpec"))
|
||||
assertFalse(diagnostics.any { it.startsWith("specPanelWait;") })
|
||||
}
|
||||
|
||||
// --- Bounded wait after the address save ---------------------------------
|
||||
|
||||
@Test
|
||||
fun `address save waits for the loading sheet without pressing back`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val driver = AddressSaveDriver(listOf(loading, loading, loading)) { expected -> readySheet(expected) }
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val proof = PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg31")
|
||||
|
||||
assertEquals("_cg31", proof.suffix)
|
||||
assertEquals(0, driver.backs)
|
||||
val done = diagnostics.single { it.startsWith("addressSaveWait;") }
|
||||
assertTrue(done.contains("outcome=final_evidence"))
|
||||
assertTrue(done.contains("waitedMs=${2 * SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS};"))
|
||||
assertTrue(done.contains("backPressed=false"))
|
||||
assertFalse(diagnostics.any { it.contains("_cg31") || it.contains("示例") || it.contains("****") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address save with a sheet that never completes fails at the bound without back`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val driver = AddressSaveDriver(listOf(loading)) { loading }
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg32") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertEquals(0, driver.backs)
|
||||
assertTrue(diagnostics.single().contains("outcome=timeout;waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};"))
|
||||
assertFalse(error.message.orEmpty().contains("示例"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address save with an unstable fallback sheet does not decide early`() {
|
||||
val first = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2216))
|
||||
val second = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2200))
|
||||
val driver = AddressSaveDriver(listOf(first, second), cycle = true) { first }
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg33") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertEquals(0, driver.backs)
|
||||
assertTrue(diagnostics.single().contains("outcome=timeout"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address save with a stable fallback sheet is accepted as purchase panel without back`() {
|
||||
val sheet = SpecPanelFixtures.liveShapedSheet()
|
||||
val driver = AddressSaveDriver(listOf(sheet)) { sheet }
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
// The stable sheet has no saved suffix and no scroll region, so the
|
||||
// existing in-panel restore path fails closed; it must not press Back.
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg34") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals(0, driver.backs)
|
||||
assertTrue(diagnostics.single().contains("outcome=stable_required;waitedMs=${SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS};"))
|
||||
assertNotEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code)
|
||||
}
|
||||
|
||||
// --- Strict final submit click -------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `submit click fails for zero size hidden unmatched or duplicated buttons`() {
|
||||
fun page(build: SpecPanelFixtures.Tree.() -> Unit) = SpecPanelFixtures.Tree(2216).apply(build).snapshot()
|
||||
val cases = mapOf(
|
||||
"hiddenZeroLabel" to SpecPanelFixtures.sheet(Sheet(submit = "hidden")),
|
||||
"zeroSizeVisibleLabel" to page {
|
||||
add("r/submit", "", NodeBounds(0, 2185, 1080, 2216), "android.widget.FrameLayout", clickable = true)
|
||||
add("r/submit/t", "提交订单", NodeBounds(0, 0, 0, 0))
|
||||
},
|
||||
"zeroSizeContainer" to page {
|
||||
add("r/submit", "", NodeBounds(0, 0, 0, 0), "android.widget.FrameLayout", clickable = true)
|
||||
add("r/submit/t", "提交订单", NodeBounds(157, 2179, 922, 2216))
|
||||
},
|
||||
"labelMismatch" to SpecPanelFixtures.sheet(),
|
||||
"duplicated" to page {
|
||||
add("r/a", "", NodeBounds(0, 2000, 540, 2216), "android.widget.FrameLayout", clickable = true)
|
||||
add("r/a/t", "提交订单", NodeBounds(20, 2050, 520, 2150))
|
||||
add("r/b", "", NodeBounds(540, 2000, 1080, 2216), "android.widget.FrameLayout", clickable = true)
|
||||
add("r/b/t", "提交订单", NodeBounds(560, 2050, 1060, 2150))
|
||||
},
|
||||
)
|
||||
cases.forEach { (name, snapshot) ->
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals(name, "PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(name, driver.clicked.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unique visible submit button is still clickable exactly once`() {
|
||||
val driver = StaticDriver(
|
||||
SpecPanelFixtures.Tree(2216).apply {
|
||||
add("r/submit", "", NodeBounds(0, 2135, 1080, 2216), "android.widget.FrameLayout", clickable = true)
|
||||
add("r/submit/t", "提交订单", NodeBounds(157, 2150, 922, 2210))
|
||||
}.snapshot(),
|
||||
)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
assertEquals(listOf("提交订单"), driver.clicked)
|
||||
}
|
||||
|
||||
// --- Helpers -------------------------------------------------------------
|
||||
|
||||
private fun readySheet(expected: String): UiSnapshot {
|
||||
val base = SpecPanelFixtures.liveShapedSheet()
|
||||
val extra = listOf(
|
||||
node("r/sheet/body/addr/a/saved", expected, NodeBounds(132, 460, 937, 480), parent = "r/sheet/body/addr/a"),
|
||||
node("r/final", "", NodeBounds(600, 2150, 1060, 2216), clickable = true, parent = "r"),
|
||||
node("r/final/t", "提交订单", NodeBounds(620, 2160, 1040, 2210), parent = "r/final"),
|
||||
)
|
||||
return base.copy(nodes = base.nodes + extra)
|
||||
}
|
||||
|
||||
private fun input() = PurchaseExecutionInput(
|
||||
taskId = 331,
|
||||
executionMode = "rehearsal",
|
||||
phase = "purchase",
|
||||
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
|
||||
goodsId = "719834019024",
|
||||
mappedColor = "黑色 示例款",
|
||||
mappedSize = "",
|
||||
quantity = 1,
|
||||
minUnitPriceCent = 1_000,
|
||||
maxUnitPriceCent = 3_000,
|
||||
)
|
||||
|
||||
private fun rule() = """{
|
||||
"schemaVersion":1,
|
||||
"ruleType":"pddPurchase",
|
||||
"requiredCapabilities":["purchase.rehearsal.v1"],
|
||||
"actions":[
|
||||
{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
|
||||
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}
|
||||
]
|
||||
}"""
|
||||
|
||||
private open class BaseDriver : PurchaseUiDriver {
|
||||
var backs = 0
|
||||
val clicked = mutableListOf<String>()
|
||||
override fun capture(): UiSnapshot = UiSnapshot(null, null, emptyList())
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
|
||||
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun backPurchase(): Boolean {
|
||||
backs++
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private class StaticDriver(private val snapshot: UiSnapshot) : BaseDriver() {
|
||||
override fun capture() = snapshot
|
||||
}
|
||||
|
||||
/** Product page first; after the spec-entry click it serves [panels] in order (the last repeats unless [cycle]). */
|
||||
private class SpecEntryDriver(private val panels: List<UiSnapshot>, private val cycle: Boolean = false) : BaseDriver() {
|
||||
private var opened = false
|
||||
var panelCaptures = 0
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
if (!opened) return SpecPanelFixtures.productDetailPage()
|
||||
val index = if (cycle) panelCaptures % panels.size else minOf(panelCaptures, panels.lastIndex)
|
||||
panelCaptures++
|
||||
return panels[index]
|
||||
}
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
if (target.label.startsWith("请选择")) opened = true
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/** Address flow driver: sheet -> address list -> editor -> [afterSave] samples -> [settled]. */
|
||||
private class AddressSaveDriver(
|
||||
private val afterSave: List<UiSnapshot>,
|
||||
private val cycle: Boolean = false,
|
||||
private val settled: (String) -> UiSnapshot,
|
||||
) : BaseDriver() {
|
||||
private var page = "sheet"
|
||||
private var address = "示例路1号-old"
|
||||
private var afterSaveCaptures = 0
|
||||
|
||||
override fun capture(): UiSnapshot = when (page) {
|
||||
"sheet" -> SpecPanelFixtures.sheet()
|
||||
"list" -> SpecPanelFixtures.Tree(2216).apply {
|
||||
add("r/title", "收货地址", NodeBounds(400, 120, 680, 200))
|
||||
add("r/modify", "", NodeBounds(900, 300, 1044, 380), "android.view.ViewGroup", clickable = true)
|
||||
add("r/modify/t", "修改", NodeBounds(920, 310, 1030, 370))
|
||||
}.snapshot()
|
||||
"edit" -> SpecPanelFixtures.Tree(2216).apply {
|
||||
add("r/title", "修改收货地址", NodeBounds(360, 120, 720, 200))
|
||||
add("r/detailLabel", "详细地址", NodeBounds(20, 400, 160, 460))
|
||||
add("r/detail", address, NodeBounds(180, 390, 900, 480), "android.widget.EditText", clickable = true)
|
||||
add("r/save", "", NodeBounds(36, 2080, 1044, 2180), "android.view.ViewGroup", clickable = true)
|
||||
add("r/save/t", "保存", NodeBounds(400, 2100, 700, 2160))
|
||||
}.snapshot()
|
||||
else -> {
|
||||
val index = afterSaveCaptures++
|
||||
when {
|
||||
cycle -> afterSave[index % afterSave.size]
|
||||
index < afterSave.size -> afterSave[index]
|
||||
else -> settled(address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
when (target.label) {
|
||||
"修改" -> page = "edit"
|
||||
"保存" -> page = "saved"
|
||||
}
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
override fun clickAddressEntryFresh(target: SnapshotNode): cn.ilapage.goauto.agent.automation.FreshClickOutcome {
|
||||
page = "list"
|
||||
return cn.ilapage.goauto.agent.automation.FreshClickOutcome(
|
||||
FreshActionResult.SUCCESS,
|
||||
cn.ilapage.goauto.agent.automation.FreshClickReason.SUCCESS,
|
||||
1,
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
|
||||
address = value
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun node(path: String, text: String, bounds: NodeBounds, clickable: Boolean = false, parent: String?) = SnapshotNode(
|
||||
path, parent, text, null, null, "android.widget.TextView", bounds, clickable, false, false, false, true, true,
|
||||
)
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import cn.ilapage.goauto.agent.network.ImageSearchImage
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ImageSearchAssetPolicyTest {
|
||||
|
||||
private fun jpegBytes(payloadSize: Int = 10): ByteArray {
|
||||
val body = ByteArray(payloadSize) { 0x11 }
|
||||
return byteArrayOf(0xff.toByte(), 0xd8.toByte()) + body + byteArrayOf(0xff.toByte(), 0xd9.toByte())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid returns true for matching jpeg with correct markers and hash`() {
|
||||
val bytes = jpegBytes()
|
||||
val reference = ImageSearchImage(
|
||||
imageUrl = "https://example.com/a.jpg",
|
||||
mediaType = "image/jpeg",
|
||||
sizeBytes = bytes.size.toLong(),
|
||||
sha256 = ImageSearchAssetPolicy.sha256(bytes),
|
||||
)
|
||||
assertTrue(ImageSearchAssetPolicy.valid(bytes, reference))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid rejects jpeg missing markers`() {
|
||||
val bytes = ByteArray(20) { 0x11 } // no ff d8 / ff d9
|
||||
val reference = ImageSearchImage(
|
||||
imageUrl = "https://example.com/a.jpg",
|
||||
mediaType = "image/jpeg",
|
||||
sizeBytes = bytes.size.toLong(),
|
||||
sha256 = ImageSearchAssetPolicy.sha256(bytes),
|
||||
)
|
||||
assertFalse(ImageSearchAssetPolicy.valid(bytes, reference))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid rejects size mismatch`() {
|
||||
val bytes = jpegBytes()
|
||||
val reference = ImageSearchImage(
|
||||
imageUrl = "https://example.com/a.jpg",
|
||||
mediaType = "image/jpeg",
|
||||
sizeBytes = bytes.size.toLong() + 1,
|
||||
sha256 = ImageSearchAssetPolicy.sha256(bytes),
|
||||
)
|
||||
assertFalse(ImageSearchAssetPolicy.valid(bytes, reference))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid rejects hash mismatch`() {
|
||||
val bytes = jpegBytes()
|
||||
val reference = ImageSearchImage(
|
||||
imageUrl = "https://example.com/a.jpg",
|
||||
mediaType = "image/jpeg",
|
||||
sizeBytes = bytes.size.toLong(),
|
||||
sha256 = "0".repeat(64),
|
||||
)
|
||||
assertFalse(ImageSearchAssetPolicy.valid(bytes, reference))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid rejects unsupported media type`() {
|
||||
val bytes = jpegBytes()
|
||||
val reference = ImageSearchImage(
|
||||
imageUrl = "https://example.com/a.gif",
|
||||
mediaType = "image/gif",
|
||||
sizeBytes = bytes.size.toLong(),
|
||||
sha256 = ImageSearchAssetPolicy.sha256(bytes),
|
||||
)
|
||||
assertFalse(ImageSearchAssetPolicy.valid(bytes, reference))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid rejects oversized payload`() {
|
||||
val bytes = jpegBytes(ImageSearchAssetPolicy.MAX_BYTES)
|
||||
val reference = ImageSearchImage(
|
||||
imageUrl = "https://example.com/a.jpg",
|
||||
mediaType = "image/jpeg",
|
||||
sizeBytes = bytes.size.toLong(),
|
||||
sha256 = ImageSearchAssetPolicy.sha256(bytes),
|
||||
)
|
||||
assertFalse(ImageSearchAssetPolicy.valid(bytes, reference))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasJpegMarkers detects ff d8 ff d9 boundaries`() {
|
||||
assertTrue(ImageSearchAssetPolicy.hasJpegMarkers(jpegBytes()))
|
||||
assertFalse(ImageSearchAssetPolicy.hasJpegMarkers(byteArrayOf(1, 2, 3)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `targetScaledSize returns null when already above minimum edge`() {
|
||||
assertNull(ImageSearchAssetPolicy.targetScaledSize(1200, 1200))
|
||||
assertNull(ImageSearchAssetPolicy.targetScaledSize(960, 960))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `targetScaledSize scales up small square image to minimum edge`() {
|
||||
val (w, h) = ImageSearchAssetPolicy.targetScaledSize(480, 480)!!
|
||||
assertEquals(960, w)
|
||||
assertEquals(960, h)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `targetScaledSize preserves aspect ratio for narrow image`() {
|
||||
// width limits: scale = 960/100 = 9.6 -> height 200*9.6=1920
|
||||
val (w, h) = ImageSearchAssetPolicy.targetScaledSize(100, 200)!!
|
||||
assertEquals(960, w)
|
||||
assertEquals(1920, h)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `targetScaledSize gives up when scaling would exceed max edge`() {
|
||||
// width 10 -> scale 96 -> height 10*96=960 within max, but width*96=960... choose extreme
|
||||
assertNull(ImageSearchAssetPolicy.targetScaledSize(1, 5000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `targetScaledSize rejects non positive dimensions`() {
|
||||
assertNull(ImageSearchAssetPolicy.targetScaledSize(0, 100))
|
||||
assertNull(ImageSearchAssetPolicy.targetScaledSize(100, 0))
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* #292:返回键推不动页面时不得空转到预算耗尽。
|
||||
*
|
||||
* 真机任务 156(2026-09-16)停在 PDD 深层页面,归位状态机 15 秒内按满 50 次返回仍未回到
|
||||
* 首页,报 IMAGE_SEARCH_ENTRY_NOT_FOUND。根因是 BACK 分支按完就走、固定 300ms 盲等,
|
||||
* 抓到的是切换动画中间态,被归为 OTHER_PDD 后继续按。
|
||||
*/
|
||||
class ImageSearchBackRecoveryTest {
|
||||
|
||||
/** 一个永远停在「其它拼多多页面」的驱动:返回键按多少次都不改变页面形态。 */
|
||||
private class StuckDriver : PddCollectorDriver {
|
||||
var backCount = 0
|
||||
override fun capture(): UiSnapshot = UiSnapshot(
|
||||
packageName = "com.xunmeng.pinduoduo",
|
||||
activityName = "com.xunmeng.pinduoduo.activity.NewPageActivity",
|
||||
nodes = listOf(node("某个商品页")),
|
||||
)
|
||||
override fun clickFresh(target: SnapshotNode) = FreshActionResult.SUCCESS
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = false
|
||||
override fun pullDownGoodsPage() = false
|
||||
override fun back(): Boolean { backCount++; return true }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun node(text: String) = SnapshotNode(
|
||||
path = "0", parentPath = null, text = text, contentDescription = null,
|
||||
resourceId = null, className = "android.widget.TextView",
|
||||
bounds = NodeBounds(0, 900, 200, 960),
|
||||
clickable = false, scrollable = false, selected = false,
|
||||
checked = false, enabled = true, visible = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stalled backs trigger a clear-top relaunch instead of burning the budget`() {
|
||||
val driver = StuckDriver()
|
||||
var resets = 0
|
||||
val automation = PinduoduoImageSearchAutomation(
|
||||
driver = driver,
|
||||
currentPackage = { "com.xunmeng.pinduoduo" },
|
||||
pause = { /* 测试里不真的睡 */ },
|
||||
)
|
||||
val result = automation.enterAndSelectImage(
|
||||
launchPdd = { PddLaunchResult.STARTED },
|
||||
resetPddToHome = { resets++; PddLaunchResult.STARTED },
|
||||
)
|
||||
|
||||
// 页面始终推不动,最终仍然明确失败——不静默成功、不无限重试。
|
||||
assertEquals("IMAGE_SEARCH_ENTRY_NOT_FOUND", result.code)
|
||||
// 关键:不再是「按满 50 次返回」,每 MAX_STALLED_BACKS 次就改用清栈重启。
|
||||
assertTrue("返回推不动时应当改用清栈重启,实际重启 $resets 次", resets > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a back that does move the page does not count as stalled`() {
|
||||
// 能推动页面时不应触发清栈重启:驱动第一次返回后就变成首页形态。
|
||||
var moved = false
|
||||
val driver = object : PddCollectorDriver {
|
||||
override fun capture(): UiSnapshot = UiSnapshot(
|
||||
packageName = "com.xunmeng.pinduoduo",
|
||||
activityName = "com.xunmeng.pinduoduo.ui.activity.MainFrameActivity",
|
||||
nodes = if (moved) {
|
||||
listOf(
|
||||
node("拍照搜索").copy(
|
||||
clickable = true,
|
||||
bounds = NodeBounds(900, 100, 1000, 200),
|
||||
contentDescription = "拍照搜索",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
listOf(node("某个商品页"))
|
||||
},
|
||||
)
|
||||
override fun clickFresh(target: SnapshotNode) = FreshActionResult.SUCCESS
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = false
|
||||
override fun pullDownGoodsPage() = false
|
||||
override fun back(): Boolean { moved = true; return true }
|
||||
}
|
||||
var resets = 0
|
||||
PinduoduoImageSearchAutomation(driver, { "com.xunmeng.pinduoduo" }, pause = {})
|
||||
.enterAndSelectImage({ PddLaunchResult.STARTED }, { resets++; PddLaunchResult.STARTED })
|
||||
assertEquals("页面被返回键推动了,不该触发清栈重启", 0, resets)
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ImageSearchRecoveryPolicyTest {
|
||||
|
||||
private val entryTexts = listOf("我的相册", "最近搜索", "历史浏览", "点击拍照", "最近项目")
|
||||
private val resultsTexts = listOf("搜图片同款", "综合", "销量", "价格", "品牌")
|
||||
private val retryTexts = listOf("请对准商品或码,保持手机稳定", "取消", "再试一次")
|
||||
private val homeTexts = listOf("拍照搜索", "首页", "推荐")
|
||||
|
||||
@Test
|
||||
fun `classify recognizes non-pdd package regardless of texts`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify("com.android.launcher", entryTexts, 0, hasClickableCameraEntry = false)
|
||||
assertEquals(ImageSearchPageKind.NON_PDD, kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify recognizes image search entry page`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", entryTexts, 0, hasClickableCameraEntry = false)
|
||||
assertEquals(ImageSearchPageKind.IMAGE_SEARCH_ENTRY, kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify recognizes results page over other markers`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", resultsTexts, 4, hasClickableCameraEntry = false)
|
||||
assertEquals(ImageSearchPageKind.IMAGE_SEARCH_RESULTS, kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify recognizes retry dialog`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", retryTexts, 0, hasClickableCameraEntry = false)
|
||||
assertEquals(ImageSearchPageKind.RETRY_DIALOG, kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify recognizes order confirm page by read-only markers without treating them as click targets`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", listOf("提交订单", "收货地址"), 0, hasClickableCameraEntry = false)
|
||||
assertEquals(ImageSearchPageKind.ORDER_CONFIRM, kind)
|
||||
// order confirm only ever maps to BACK -- never a click action.
|
||||
assertEquals(ImageSearchRecoveryAction.BACK, ImageSearchRecoveryPolicy.nextAction(kind))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify recognizes home page only when a clickable camera entry exists`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify(
|
||||
"com.xunmeng.pinduoduo", homeTexts, 0, hasClickableCameraEntry = true,
|
||||
)
|
||||
assertEquals(ImageSearchPageKind.HOME, kind)
|
||||
}
|
||||
|
||||
// 真机核对(2026-09-15):个人中心 tab 的根节点也带 content-desc='拍照搜索',
|
||||
// 覆盖全屏且不可点,而 visibleTexts() 会把 contentDescription 当文本收集。
|
||||
// 只看文案会把个人中心判成首页,几何兜底随后会点到「设置」。
|
||||
@Test
|
||||
fun `classify refuses home when camera text is present but nothing clickable carries it`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify(
|
||||
"com.xunmeng.pinduoduo", homeTexts, 0, hasClickableCameraEntry = false,
|
||||
)
|
||||
assertEquals(ImageSearchPageKind.OTHER_PDD, kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify falls back to other pdd page`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", listOf("商品详情", "加入购物车"), 0, hasClickableCameraEntry = false)
|
||||
assertEquals(ImageSearchPageKind.OTHER_PDD, kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nextAction maps each page kind to the expected recovery action`() {
|
||||
assertEquals(ImageSearchRecoveryAction.NONE, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.IMAGE_SEARCH_ENTRY))
|
||||
assertEquals(ImageSearchRecoveryAction.CLICK_CAMERA, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.HOME))
|
||||
assertEquals(ImageSearchRecoveryAction.LAUNCH_PDD, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.NON_PDD))
|
||||
assertEquals(ImageSearchRecoveryAction.BACK, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.IMAGE_SEARCH_RESULTS))
|
||||
// RETRY_DIALOG 不再是 BACK,理由见下方 retry dialog must be dismissed by cancel。
|
||||
assertEquals(ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.RETRY_DIALOG))
|
||||
assertEquals(ImageSearchRecoveryAction.BACK, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.OTHER_PDD))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recovery budget caps at the documented limit`() {
|
||||
assertFalse(ImageSearchRecoveryPolicy.exceedsRecoveryBudget(ImageSearchRecoveryPolicy.MAX_RECOVERY_ACTIONS - 1))
|
||||
assertTrue(ImageSearchRecoveryPolicy.exceedsRecoveryBudget(ImageSearchRecoveryPolicy.MAX_RECOVERY_ACTIONS))
|
||||
}
|
||||
|
||||
// `[必须]` 2026-09-15 真机实测:按 BACK 对这个弹窗完全无效,前后两次 uiautomator
|
||||
// dump 逐字节相同;唯一出口是点「取消」。此前把它映射成 BACK,导致任务在弹窗上
|
||||
// 空转直到预算耗尽、报 IMAGE_SEARCH_ENTRY_NOT_FOUND,而弹窗一直留在屏幕上。
|
||||
@Test
|
||||
fun `retry dialog must be dismissed by cancel, never by back`() {
|
||||
val kind = ImageSearchRecoveryPolicy.classify(
|
||||
"com.xunmeng.pinduoduo", retryTexts, 0, hasClickableCameraEntry = false,
|
||||
)
|
||||
assertEquals(ImageSearchPageKind.RETRY_DIALOG, kind)
|
||||
assertEquals(
|
||||
ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG,
|
||||
ImageSearchRecoveryPolicy.nextAction(kind),
|
||||
)
|
||||
}
|
||||
|
||||
// 其余需要归位的页面仍然用返回,不受上面那条影响。
|
||||
@Test
|
||||
fun `other pages still recover by back`() {
|
||||
for (kind in listOf(
|
||||
ImageSearchPageKind.IMAGE_SEARCH_RESULTS,
|
||||
ImageSearchPageKind.ORDER_CONFIRM,
|
||||
ImageSearchPageKind.OTHER_PDD,
|
||||
)) {
|
||||
assertEquals(
|
||||
"$kind 的归位动作被改动了",
|
||||
ImageSearchRecoveryAction.BACK,
|
||||
ImageSearchRecoveryPolicy.nextAction(kind),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗会退出图搜回到首页,重新进入又会撞上同一个约 4 秒的摄像头识别窗口,
|
||||
// 因此必须有独立且很小的上限,不能落在通用的 50 次预算里空转数分钟。
|
||||
@Test
|
||||
fun `retry dialog recovery budget is separate and small`() {
|
||||
assertTrue(
|
||||
"弹窗恢复上限必须显著小于通用恢复预算",
|
||||
ImageSearchRecoveryPolicy.MAX_RETRY_DIALOG_RECOVERIES in
|
||||
1 until ImageSearchRecoveryPolicy.MAX_RECOVERY_ACTIONS / 5,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class ImageSearchCandidatePolicyTest {
|
||||
|
||||
private fun card(top: Int, left: Int, ad: Boolean = false, loaded: Boolean = true) =
|
||||
ImageSearchResultCard(top = top, left = left, width = 535, height = 764, texts = if (ad) listOf("广告", "¥19.9") else listOf("¥19.9"), imageLoaded = loaded)
|
||||
|
||||
@Test
|
||||
fun `picks the first card in reading order`() {
|
||||
val cards = listOf(card(top = 1085, left = 0), card(top = 312, left = 0), card(top = 312, left = 545))
|
||||
val chosen = ImageSearchCandidatePolicy.firstOpenableCandidate(cards)
|
||||
assertEquals(312, chosen?.top)
|
||||
assertEquals(0, chosen?.left)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips ad cards and takes the next candidate`() {
|
||||
val cards = listOf(
|
||||
card(top = 312, left = 0),
|
||||
card(top = 312, left = 545, ad = true),
|
||||
card(top = 1085, left = 0, ad = true),
|
||||
card(top = 1085, left = 545),
|
||||
)
|
||||
val chosen = ImageSearchCandidatePolicy.firstOpenableCandidate(cards)
|
||||
assertEquals(312, chosen?.top)
|
||||
assertEquals(0, chosen?.left)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tolerates a candidate whose image has not finished loading`() {
|
||||
val cards = listOf(card(top = 312, left = 0, loaded = false))
|
||||
val chosen = ImageSearchCandidatePolicy.firstOpenableCandidate(cards)
|
||||
assertEquals(312, chosen?.top)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when every candidate is an ad`() {
|
||||
val cards = listOf(card(top = 312, left = 0, ad = true), card(top = 312, left = 545, ad = true))
|
||||
assertNull(ImageSearchCandidatePolicy.firstOpenableCandidate(cards))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null for an empty candidate list`() {
|
||||
assertNull(ImageSearchCandidatePolicy.firstOpenableCandidate(emptyList()))
|
||||
}
|
||||
}
|
||||
|
||||
class ImageSearchClickTargetTest {
|
||||
|
||||
private fun node(top: Int, left: Int, right: Int, bottom: Int, text: String?) = SnapshotNode(
|
||||
path = "0/$top/$left", parentPath = "0", text = text, contentDescription = null,
|
||||
resourceId = null, className = "android.widget.TextView",
|
||||
bounds = NodeBounds(left, top, right, bottom),
|
||||
clickable = false, scrollable = false, selected = false, checked = false,
|
||||
enabled = true, visible = true,
|
||||
)
|
||||
|
||||
// 真机 dump(2026-09-15)的第一张卡片:[0,600] 535x764。
|
||||
private val card = ImageSearchResultCard(top = 600, left = 0, width = 535, height = 764, texts = emptyList())
|
||||
|
||||
// `[必须]` 卡片容器自身无文案,label 为空串;clickFreshDetailed 重找时用的是
|
||||
// preferredOrDescendantLabel()(会挖子孙),两者永远对不上,直接 TARGET_NOT_FOUND。
|
||||
// 所以点击目标必须是卡片内有文案的子节点。
|
||||
@Test
|
||||
fun `click target is the topmost labelled node inside the card`() {
|
||||
val nodes = listOf(
|
||||
node(600, 0, 535, 1364, null), // 卡片容器本身,无文案
|
||||
node(1100, 20, 300, 1140, "¥21.8"),
|
||||
node(1000, 20, 500, 1060, "港风时尚"), // 最靠上的有文案节点
|
||||
)
|
||||
val target = ImageSearchCandidatePolicy.clickTargetInside(card, nodes)
|
||||
assertEquals("港风时尚", target?.label)
|
||||
}
|
||||
|
||||
// 相邻卡片的文案不得被当成本卡片的内容。真机上右侧卡片从 x=545 开始。
|
||||
@Test
|
||||
fun `nodes outside the card bounds are ignored`() {
|
||||
val nodes = listOf(node(1000, 565, 1040, 1060, "森系假两件"))
|
||||
assertNull(ImageSearchCandidatePolicy.clickTargetInside(card, nodes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `card without any labelled child yields no target`() {
|
||||
assertNull(ImageSearchCandidatePolicy.clickTargetInside(card, listOf(node(700, 10, 500, 900, null))))
|
||||
}
|
||||
}
|
||||
|
||||
class ImageSearchMediaRecencyPolicyTest {
|
||||
|
||||
@Test
|
||||
fun `is most recent when our entry sorts first`() {
|
||||
val entries = listOf(
|
||||
ImageSearchMediaEntry(id = 100, dateAdded = 2_000),
|
||||
ImageSearchMediaEntry(id = 90, dateAdded = 1_000),
|
||||
)
|
||||
assertTrue(ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId = 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails when a newer video sorts ahead of our image`() {
|
||||
// Regression for pitfall 2: the recent-items grid mixes photos and videos; if a
|
||||
// freshly-recorded video is newer than the jpeg we wrote, we must not claim first slot.
|
||||
val entries = listOf(
|
||||
ImageSearchMediaEntry(id = 101, dateAdded = 3_000), // newer video, not ours
|
||||
ImageSearchMediaEntry(id = 100, dateAdded = 2_000), // our prepared jpeg
|
||||
)
|
||||
assertFalse(ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId = 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails when our own entry is missing from the query result`() {
|
||||
val entries = listOf(ImageSearchMediaEntry(id = 5, dateAdded = 1_000))
|
||||
assertFalse(ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId = 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails on an empty media list rather than guessing`() {
|
||||
assertFalse(ImageSearchMediaRecencyPolicy.isMostRecent(emptyList(), ownId = 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ties break by id so a same-timestamp newer insert still wins`() {
|
||||
val entries = listOf(
|
||||
ImageSearchMediaEntry(id = 101, dateAdded = 2_000),
|
||||
ImageSearchMediaEntry(id = 100, dateAdded = 2_000),
|
||||
)
|
||||
assertFalse(ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId = 100))
|
||||
assertTrue(ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId = 101))
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PinduoduoImageSearchCriteriaTest {
|
||||
|
||||
@Test
|
||||
fun `isImageSearchEntryPage requires all four signals`() {
|
||||
val complete = listOf("我的相册", "最近搜索", "历史浏览", "点击拍照即可开始识别")
|
||||
assertTrue(PinduoduoImageSearchCriteria.isImageSearchEntryPage(complete))
|
||||
|
||||
val missingHint = listOf("我的相册", "最近搜索", "历史浏览")
|
||||
assertFalse(PinduoduoImageSearchCriteria.isImageSearchEntryPage(missingHint))
|
||||
|
||||
val alternateHint = listOf("我的相册", "最近搜索", "历史浏览", "开启相机权限")
|
||||
assertTrue(PinduoduoImageSearchCriteria.isImageSearchEntryPage(alternateHint))
|
||||
|
||||
val thirdHintVariant = listOf("我的相册", "最近搜索", "历史浏览", "拍摄清晰照片即可进行自动识别")
|
||||
assertTrue(PinduoduoImageSearchCriteria.isImageSearchEntryPage(thirdHintVariant))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isImageSearchResultsPage requires title and enough sort controls`() {
|
||||
assertTrue(PinduoduoImageSearchCriteria.isImageSearchResultsPage(listOf("搜图片同款"), sortControlCount = 3))
|
||||
assertFalse(PinduoduoImageSearchCriteria.isImageSearchResultsPage(listOf("搜图片同款"), sortControlCount = 2))
|
||||
assertFalse(PinduoduoImageSearchCriteria.isImageSearchResultsPage(listOf("其他标题"), sortControlCount = 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isRetryDialog requires hint text plus both buttons`() {
|
||||
val texts = listOf("请对准商品或码,保持手机稳定", "取消", "再试一次")
|
||||
assertTrue(PinduoduoImageSearchCriteria.isRetryDialog(texts))
|
||||
assertFalse(PinduoduoImageSearchCriteria.isRetryDialog(listOf("请对准商品或码,保持手机稳定", "取消")))
|
||||
assertFalse(PinduoduoImageSearchCriteria.isRetryDialog(listOf("取消", "再试一次")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `firstRecentImageGridCell picks topmost then leftmost four column cell`() {
|
||||
val screenWidth = 1080
|
||||
val cellWidth = screenWidth / 4 // 270
|
||||
val candidates = listOf(
|
||||
PinduoduoImageSearchCriteria.GridCandidate(top = 200, left = cellWidth, width = cellWidth, height = cellWidth),
|
||||
PinduoduoImageSearchCriteria.GridCandidate(top = 100, left = cellWidth * 2, width = cellWidth, height = cellWidth),
|
||||
PinduoduoImageSearchCriteria.GridCandidate(top = 100, left = 0, width = cellWidth, height = cellWidth),
|
||||
// not a 4-column cell (too narrow height ratio)
|
||||
PinduoduoImageSearchCriteria.GridCandidate(top = 50, left = 0, width = cellWidth, height = 10),
|
||||
)
|
||||
val result = PinduoduoImageSearchCriteria.firstRecentImageGridCell(candidates, screenWidth)
|
||||
assertTrue(result != null)
|
||||
assertTrue(result!!.top == 100 && result.left == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `firstRecentImageGridCell returns null when nothing matches grid shape`() {
|
||||
val screenWidth = 1080
|
||||
val candidates = listOf(
|
||||
PinduoduoImageSearchCriteria.GridCandidate(top = 0, left = 0, width = 1080, height = 100),
|
||||
)
|
||||
assertNull(PinduoduoImageSearchCriteria.firstRecentImageGridCell(candidates, screenWidth))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class TaskDispatchPolicyTest {
|
||||
|
||||
// 四条既有路径的回归。图搜任务由服务端 ORDER BY 垫底(见
|
||||
// task.Service.Next),客户端这里不参与来源优先级,保持四分支不变。
|
||||
|
||||
@Test
|
||||
fun `spec probe pending waits for purchase match regardless of cooldown`() {
|
||||
assertEquals(
|
||||
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH,
|
||||
TaskDispatchPolicy.decide("spec_probe_pending", collectionCooldownActive = false),
|
||||
)
|
||||
assertEquals(
|
||||
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH,
|
||||
TaskDispatchPolicy.decide("spec_probe_pending", collectionCooldownActive = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `any other purchase status runs purchase`() {
|
||||
assertEquals(
|
||||
TaskDispatchDecision.RUN_PURCHASE,
|
||||
TaskDispatchPolicy.decide("matched", collectionCooldownActive = false),
|
||||
)
|
||||
assertEquals(
|
||||
TaskDispatchDecision.RUN_PURCHASE,
|
||||
TaskDispatchPolicy.decide("matched", collectionCooldownActive = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no purchase status and cooldown active waits for cooldown`() {
|
||||
assertEquals(
|
||||
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN,
|
||||
TaskDispatchPolicy.decide(null, collectionCooldownActive = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no purchase status and no cooldown checks collection`() {
|
||||
assertEquals(
|
||||
TaskDispatchDecision.CHECK_COLLECTION,
|
||||
TaskDispatchPolicy.decide(null, collectionCooldownActive = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,7 @@
|
||||
# GoAuto PDD 订单回填扩展
|
||||
|
||||
Manifest V3 轻量扩展。用户在已登录的 PDD 我的订单页手动开始;扩展读取必要订单事实,先完成本次范围内的 CG 冲突聚合,再通过已授权 Admin Origin 的客户端密钥接口分批提交。
|
||||
|
||||
密钥只保存在扩展可信上下文,内容脚本不能读取;网络请求禁止重定向且不携带 Cookie。当前自动化测试只使用合成文本与合成 DOM fixture,不包含真实订单、地址、Cookie 或网页 HTML。
|
||||
|
||||
开发验证:`npm test`。解压安装、真实 PDD 遍历和真实 Admin 上传必须另行授权,本仓库验证不会执行这些动作。
|
||||
@@ -0,0 +1,9 @@
|
||||
"use strict";const Core=self.GoAutoBackfillCore,SAFE=/^(订单详情|查看详情)$/,RISK=/(付款|支付|确认收货|退款|售后|取消订单|更多)/,END=/^(没有更多了|没有更多|已经到底了)$/,LOGIN=/(请登录|登录后查看|验证码|安全验证)/;
|
||||
const txt=e=>(e?.innerText||e?.textContent||"").trim(),vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0},clickable=e=>e.closest("button,a,[role=button],[tabindex]");
|
||||
function entries(){const explicit=[...document.querySelectorAll("button,a,[role=button],[tabindex],div,span")].filter(e=>vis(e)&&SAFE.test(txt(e))).map(clickable).filter(Boolean).filter(e=>!RISK.test(txt(e)));if(explicit.length)return[...new Set(explicit)];return[...document.querySelectorAll(".react-base-list > div")].map(card=>[...card.children].slice(1).find(row=>vis(row)&&row.querySelector("img")&&!RISK.test(txt(row)))).filter(Boolean)}
|
||||
function summary(){const b=txt(document.body);return{ok:true,entryCount:entries().length,endEvidence:[...document.querySelectorAll("div,span,p")].some(e=>vis(e)&&END.test(txt(e))),loginExpired:LOGIN.test(b),empty:/^(暂无订单|没有订单)$/m.test(b),scrollY,scrollHeight:document.documentElement.scrollHeight}}
|
||||
function labelValue(labels){for(const n of document.querySelectorAll("div,span,p,li")){const own=txt(n).replace(/[::]$/u,"").trim();if(!labels.includes(own))continue;const row=n.parentElement,parts=[...row.children].filter(x=>x!==n).map(x=>txt(x).replace(/复制\s*$/u,"").trim()).filter(Boolean);if(parts.length)return parts.join("")}return""}
|
||||
function paidValue(){for(const n of document.querySelectorAll("div,span,p,li")){if(!["实付","实际支付"].includes(txt(n).replace(/[::]$/u,"").trim()))continue;for(const x of n.parentElement.children){const value=txt(x);if(x!==n&&Core.moneyCent(value)!=null)return value}}return""}
|
||||
async function detail(){const controls=[...new Set([...document.querySelectorAll("button,[role=button],[tabindex],div,span")].filter(e=>vis(e)&&/^(展开|展开收货信息|展开订单信息|查看完整信息|订单信息)$/.test(txt(e))).map(clickable).filter(Boolean).filter(e=>!RISK.test(txt(e))))];for(const control of controls.slice(0,2)){control.click();await new Promise(r=>setTimeout(r,100))}let stable=0,last="",until=Date.now()+5000;while(Date.now()<until){await new Promise(r=>setTimeout(r,250));const order=labelValue(["订单号"]),time=labelValue(["下单时间","创建时间"]),v=`${order}|${time}`;stable=order&&time&&v===last?stable+1:0;last=v;if(stable>=2)break}return{address:txt(document.body),orderNo:labelValue(["订单号"]),submittedAt:labelValue(["下单时间","创建时间"]),paidAmount:paidValue()}}
|
||||
chrome.runtime.onMessage.addListener((m,_s,reply)=>{if(m?.source!=="goauto-service"||!["LIST","SCROLL","OPEN_ENTRY","DETAIL","RESTORE"].includes(m.type))return;(async()=>{if(m.type==="LIST")return summary();if(m.type==="SCROLL"){scrollTo({top:document.documentElement.scrollHeight,behavior:"instant"});return summary()}if(m.type==="RESTORE"){scrollTo({top:Number(m.scrollY)||0,behavior:"instant"});return summary()}if(m.type==="OPEN_ENTRY"){const e=entries();if(!e[m.index])throw Error("安全的订单详情入口不存在");e[m.index].click();return{ok:true}}if(LOGIN.test(txt(document.body)))return{ok:false,loginExpired:true};return{ok:true,classified:Core.classifyDetail(await detail())}})().then(reply).catch(e=>reply({ok:false,error:e.message}));return true});
|
||||
if(typeof module!=="undefined")module.exports={entries,summary,labelValue,paidValue,detail};
|
||||
@@ -0,0 +1,15 @@
|
||||
(function(root){"use strict";
|
||||
const MAX_BATCH=50,CG=/_cg([1-9]\d*)\b/ig,TERMINAL=new Set(["completed","incomplete","stopped","failed"]);
|
||||
function normalizeOrigin(raw){const u=new URL(String(raw||"").trim());if(!/^https?:$/.test(u.protocol)||u.username||u.password||u.pathname!=="/"||u.search||u.hash)throw Error("Admin Origin 必须是 http(s) 根地址");return u.origin}
|
||||
function addressSuffixes(v){return[...new Set([...String(v||"").matchAll(CG)].map(m=>`_cg${m[1]}`))]}
|
||||
function addressSuffix(v){const a=addressSuffixes(v);return a.length===1?a[0]:""}
|
||||
function moneyCent(v){const m=String(v||"").trim().replace(/,/g,"").match(/^(?:¥|¥)?\s*(0|[1-9]\d*)(?:\.(\d{1,2}))?\s*元?$/);return m?Number(m[1])*100+Number((m[2]||"").padEnd(2,"0")):null}
|
||||
function normalizeOrderNo(v){const s=String(v||"").replace(/\s*复制\s*$/u,"").trim();return/^[A-Za-z0-9-]{1,100}$/.test(s)?s:""}
|
||||
function normalizeTime(v){const m=String(v||"").trim().match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/);if(!m)return null;const p=n=>String(n).padStart(2,"0"),d=new Date(`${m[1]}-${p(m[2])}-${p(m[3])}T${p(m[4])}:${m[5]}:${p(m[6]||0)}+08:00`);return Number.isNaN(d.valueOf())?null:d.toISOString()}
|
||||
function classifyDetail(f){const s=addressSuffixes(f.address||""),item={addressSuffix:s.length===1?s[0]:"",pddOrderNo:normalizeOrderNo(f.orderNo),orderSubmittedAt:normalizeTime(f.submittedAt),pddOrderAmountCent:f.paidAmount==null||f.paidAmount===""?null:moneyCent(f.paidAmount)};if(s.length>1)return{kind:"error",code:"CG_AMBIGUOUS",item};if(!s.length)return{kind:"skip",code:"CG_MISSING",item};if(!item.pddOrderNo)return{kind:"error",code:"ORDER_NO_INVALID",item};if(!item.orderSubmittedAt)return{kind:"error",code:"ORDER_TIME_INVALID",item};if(f.paidAmount!=null&&f.paidAmount!==""&&item.pddOrderAmountCent==null)return{kind:"error",code:"ORDER_AMOUNT_INVALID",item};return{kind:"candidate",code:"",item}}
|
||||
function aggregate(rows){const cs=rows.filter(x=>x.kind==="candidate").map(x=>x.item),cg=new Map,orders=new Map,uniq=new Map;for(const x of cs){if(!cg.has(x.addressSuffix))cg.set(x.addressSuffix,new Set);if(!orders.has(x.pddOrderNo))orders.set(x.pddOrderNo,new Set);cg.get(x.addressSuffix).add(x.pddOrderNo);orders.get(x.pddOrderNo).add(x.addressSuffix);uniq.set(`${x.addressSuffix}\0${x.pddOrderNo}`,x)}const cc=new Set([...cg].filter(([,v])=>v.size>1).map(([k])=>k)),oc=new Set([...orders].filter(([,v])=>v.size>1).map(([k])=>k));return{ready:[...uniq.values()].filter(x=>!cc.has(x.addressSuffix)&&!oc.has(x.pddOrderNo)),cgConflicts:[...cc],orderConflicts:[...oc],skippedNoCG:rows.filter(x=>x.code==="CG_MISSING").length,localErrors:rows.filter(x=>x.kind==="error")}}
|
||||
function batches(a,n=MAX_BATCH){const o=[];for(let i=0;i<a.length;i+=n)o.push(a.slice(i,i+n));return o}
|
||||
function inWindow(x,start,days){return new Date(x.orderSubmittedAt).valueOf()>=new Date(start).valueOf()-Number(days)*86400000}
|
||||
function validPddURL(v){try{const u=new URL(v);return u.protocol==="https:"&&u.hostname==="mobile.yangkeduo.com"}catch(_){return false}}
|
||||
function trustedUISender(s,id){try{const u=new URL(s.url);return s?.id===id&&!s.tab&&u.protocol==="chrome-extension:"&&u.hostname===id}catch(_){return false}}
|
||||
const api={MAX_BATCH,normalizeOrigin,addressSuffixes,addressSuffix,moneyCent,normalizeOrderNo,normalizeTime,classifyDetail,aggregate,batches,inWindow,validPddURL,trustedUISender,terminal:p=>TERMINAL.has(p)};if(typeof module!=="undefined")module.exports=api;root.GoAutoBackfillCore=api})(typeof self!=="undefined"?self:globalThis);
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "GoAuto PDD 订单回填",
|
||||
"version": "0.1.0",
|
||||
"description": "手动扫描最近 PDD 订单并将必要订单事实回填到已授权的 GoAuto Admin。",
|
||||
"permissions": ["storage", "tabs"],
|
||||
"host_permissions": ["https://mobile.yangkeduo.com/*"],
|
||||
"optional_host_permissions": ["https://*/*", "http://*/*"],
|
||||
"background": {"service_worker": "service-worker.js"},
|
||||
"action": {"default_popup": "popup.html"},
|
||||
"content_scripts": [{
|
||||
"matches": ["https://mobile.yangkeduo.com/*"],
|
||||
"js": ["core.js", "content.js"],
|
||||
"run_at": "document_idle"
|
||||
}]
|
||||
}
|
||||
Generated
+545
@@ -0,0 +1,545 @@
|
||||
{
|
||||
"name": "goauto-pdd-order-backfill",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goauto-pdd-order-backfill",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"jsdom": "^26.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^2.1.3",
|
||||
"@csstools/css-color-parser": "^3.0.9",
|
||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
||||
"@csstools/css-tokenizer": "^3.0.3",
|
||||
"lru-cache": "^10.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
|
||||
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^5.1.0",
|
||||
"@csstools/css-calc": "^2.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
||||
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^3.2.0",
|
||||
"rrweb-cssom": "^0.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
"decimal.js": "^10.5.0",
|
||||
"html-encoding-sniffer": "^4.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"nwsapi": "^2.2.16",
|
||||
"parse5": "^7.2.1",
|
||||
"rrweb-cssom": "^0.8.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^5.1.1",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^7.0.0",
|
||||
"whatwg-encoding": "^3.1.1",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.1.1",
|
||||
"ws": "^8.18.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.27",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz",
|
||||
"integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/rrweb-cssom": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^6.1.86"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^6.1.32"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
||||
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "^5.1.0",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"goauto-pdd-order-backfill","private":true,"version":"0.1.0","scripts":{"test":"node --test test/*.test.js"},"devDependencies":{"jsdom":"^26.1.0"}}
|
||||
@@ -0,0 +1 @@
|
||||
:root{font-family:Arial,sans-serif;color:#134e4a;background:#f0fdfa}body{margin:0;width:390px}main{padding:20px}h1{font-size:24px;margin:0 0 6px}.muted,.help{color:#64748b;font-size:12px}label{display:block;font-weight:700;margin-top:14px}input{box-sizing:border-box;width:100%;height:42px;margin-top:6px;border:1px solid #cbd5e1;border-radius:8px;padding:0 12px;font-size:14px}.notice{margin:18px 0;padding:12px;border:1px solid #99f6e4;border-radius:10px;background:#fff;font-size:12px;line-height:1.5}button{width:100%;height:46px;border:0;border-radius:10px;background:#0d9488;color:#fff;font-weight:700;font-size:15px;cursor:pointer;margin-top:10px}.secondary{background:#fff;color:#0d9488;border:1px solid #0d9488}button:disabled,input:disabled{opacity:.55;cursor:not-allowed}#error{color:#b91c1c;font-size:12px}#status{white-space:pre-line;font-size:13px;line-height:1.6;margin-top:12px}
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><link rel="stylesheet" href="popup.css"><title>PDD 订单回填</title></head><body>
|
||||
<main><h1>PDD 订单回填</h1><p class="muted">扫描最近订单,聚合冲突后分批上传 Admin。</p>
|
||||
<label>Admin Origin<input id="origin" type="url" placeholder="https://admin.example.internal"></label>
|
||||
<label>客户端密钥<input id="key" type="password" autocomplete="off" placeholder="仅保存在扩展可信上下文"></label>
|
||||
<p class="help">密钥需授权:采购管理 → 回填。</p>
|
||||
<label>最近天数<input id="days" type="number" min="1" max="30" value="2"></label>
|
||||
<div class="notice">开始后冻结配置与时间窗口;完成扫描并聚合冲突后才上传。</div>
|
||||
<button id="start">开始扫描并上传</button><button id="stop" class="secondary">停止</button>
|
||||
<p id="error" role="alert"></p><section id="status" aria-live="polite"></section><button id="retry" class="secondary" hidden>重试可重试项</button><ol id="results"></ol></main><script src="core.js"></script><script src="popup.js"></script></body></html>
|
||||
@@ -0,0 +1,6 @@
|
||||
"use strict";const Core=self.GoAutoBackfillCore,$=id=>document.getElementById(id);let timer;
|
||||
const command=(type,data={})=>new Promise(resolve=>chrome.runtime.sendMessage({source:"goauto-popup",type,...data},resolve));
|
||||
async function load(){const c=await chrome.storage.local.get(["adminOrigin","clientKey","days"]);$("origin").value=c.adminOrigin||"";$("key").value=c.clientKey||"";$("days").value=c.days||2;await refresh();timer=setInterval(refresh,750)}
|
||||
async function refresh(){const q=await command("STATUS"),r=q?.run;if(!r){$("status").textContent="空闲";return}const busy=!Core.terminal(r.phase),complete=r.scanComplete?"已确认列表末尾":"未完整扫描";$("status").textContent=`状态:${r.phase}(${complete})\n已读 ${r.stats.read} · 可提交 ${r.stats.ready} · 已写入 ${r.stats.written}\n冲突 ${r.stats.conflicts} · 无 CG 跳过 ${r.stats.skippedNoCG}${r.incompleteReason?`\n${r.incompleteReason}`:""}${r.error?`\n${r.error.message}`:""}`;for(const id of["origin","key","days","start"])$(id).disabled=busy;$("stop").disabled=!busy;$("retry").hidden=!(r.phase==="incomplete"&&r.batches?.some(b=>b.status==="retryable"));const pending=(r.batches||[]).filter(b=>b.status==="retryable").flatMap(b=>b.results||[]),errors=(r.localErrors||[]).map((x,i)=>({index:i,result:"local_error",code:x.code}));$("results").replaceChildren(...[...(r.results||[]),...pending,...errors].map(x=>{const li=document.createElement("li");li.textContent=`第 ${Number(x.index)+1} 项:${x.result} / ${x.code}`;return li}))}
|
||||
$("start").addEventListener("click",async()=>{try{$("error").textContent="";const origin=Core.normalizeOrigin($("origin").value),clientKey=$("key").value.trim(),days=Number($("days").value);if(!clientKey||!Number.isInteger(days)||days<1||days>30)throw Error("请填写密钥,最近天数须为 1–30");if(!await chrome.permissions.request({origins:[`${origin}/*`]}))throw Error("未授予此 Admin Origin 的访问权限");await chrome.storage.local.setAccessLevel({accessLevel:"TRUSTED_CONTEXTS"});await chrome.storage.local.set({adminOrigin:origin,clientKey,days});const[tab]=await chrome.tabs.query({active:true,currentWindow:true}),res=await command("START",{tabId:tab.id,clientKey,frozen:{origin,days,startedAt:new Date().toISOString()}});if(!res?.ok)throw Error(res?.error||"启动失败");await refresh()}catch(e){$("error").textContent=e.message}});
|
||||
$("stop").addEventListener("click",async()=>{await command("STOP");await refresh()});$("retry").addEventListener("click",async()=>{await command("RETRY");await refresh()});addEventListener("unload",()=>clearInterval(timer));load();
|
||||
@@ -0,0 +1,24 @@
|
||||
"use strict";importScripts("core.js");const Core=self.GoAutoBackfillCore,STATE="orderBackfillRun",SECRET="orderBackfillSecret";let active=null,starting=false;
|
||||
const delay=ms=>new Promise(r=>setTimeout(r,ms)),uuid=()=>crypto.randomUUID();
|
||||
async function secure(){await chrome.storage.local.setAccessLevel({accessLevel:"TRUSTED_CONTEXTS"});await chrome.storage.session.setAccessLevel({accessLevel:"TRUSTED_CONTEXTS"})}
|
||||
chrome.runtime.onInstalled.addListener(secure);chrome.runtime.onStartup.addListener(()=>{secure();resume()});secure();
|
||||
async function getRun(){return(await chrome.storage.session.get(STATE))[STATE]||null}async function getSecret(){return(await chrome.storage.session.get(SECRET))[SECRET]||null}
|
||||
async function write(run){const latest=await getRun();if(latest&&latest.id!==run.id)throw Error("运行已被替换");if(latest?.stopRequested)run.stopRequested=true;await chrome.storage.session.set({[STATE]:run})}
|
||||
async function cancelled(run){const latest=await getRun();return!latest||latest.id!==run.id||latest.stopRequested}
|
||||
async function send(run,type,extra={}){if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});let done=false,value,error;chrome.tabs.sendMessage(run.tabId,{source:"goauto-service",type,...extra}).then(v=>{done=true;value=v},e=>{done=true;error=e});const end=Date.now()+(self.__GOAUTO_TEST_TIMEOUT__||15000);while(!done&&Date.now()<end){if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});await delay(100)}if(!done){if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});throw Object.assign(Error("页面响应超时"),{code:"CONTENT_TIMEOUT"})}if(error)throw error;return value}
|
||||
async function loaded(tabId,timeout=15000){const end=Date.now()+timeout;while(Date.now()<end){if((await chrome.tabs.get(tabId)).status==="complete"){await delay(300);return}await delay(200)}throw Object.assign(Error("页面加载超时"),{code:"PAGE_TIMEOUT"})}
|
||||
async function navigated(run,from,timeout=15000){const end=Date.now()+timeout;while(Date.now()<end){if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});const tab=await chrome.tabs.get(run.tabId);if(tab.url!==from&&tab.status==="complete"){await delay(300);return}await delay(150)}throw Object.assign(Error("订单详情未打开"),{code:"DETAIL_NOT_OPENED"})}
|
||||
async function returnList(run){await chrome.tabs.goBack(run.tabId);await loaded(run.tabId);const tab=await chrome.tabs.get(run.tabId);if(!tab.url.startsWith(run.listURL.split("?")[0]))await chrome.tabs.update(run.tabId,{url:run.listURL});await loaded(run.tabId);await send(run,"RESTORE",{scrollY:run.scrollY})}
|
||||
function isListURL(raw){try{return new URL(raw).pathname==="/orders.html"}catch(_){return false}}
|
||||
function prepare(run){const inRange=run.rows.filter(x=>x.kind!=="candidate"||Core.inWindow(x.item,run.frozen.startedAt,run.frozen.days));const grouped=Core.aggregate(inRange);run.localErrors=grouped.localErrors;run.stats.ready=grouped.ready.length;run.stats.conflicts=grouped.cgConflicts.length+grouped.orderConflicts.length;run.stats.skippedNoCG=grouped.skippedNoCG;run.batches=Core.batches(grouped.ready).map((items,i)=>({index:i,requestId:uuid(),status:"pending",items,results:[],attempts:0}));}
|
||||
function validate(payload,count){const items=payload?.data?.items;if(!Array.isArray(items)||items.length!==count)throw Object.assign(Error("服务端返回结果不完整"),{code:"INVALID_RESPONSE"});const seen=new Set;for(const x of items){if(!Number.isInteger(x.index)||x.index<0||x.index>=count||seen.has(x.index)||(x.taskId!=null&&(!Number.isInteger(x.taskId)||x.taskId<0))||typeof x.result!=="string"||typeof x.code!=="string"||typeof x.retryable!=="boolean")throw Object.assign(Error("服务端返回结果索引或字段无效"),{code:"INVALID_RESPONSE"});seen.add(x.index)}return items.sort((a,b)=>a.index-b.index)}
|
||||
async function post(run,batch){const secret=await getSecret();if(!secret||secret.runId!==run.id||secret.origin!==run.frozen.origin)throw Error("冻结的运行凭据不可用");const ctl=new AbortController,t=setTimeout(()=>ctl.abort(),self.__GOAUTO_TEST_TIMEOUT__||15000);try{const res=await fetch(`${run.frozen.origin}/api/client/v1/purchase-tasks/order-backfill`,{method:"POST",redirect:"error",cache:"no-store",credentials:"omit",signal:ctl.signal,headers:{Authorization:`Bearer ${secret.clientKey}`,"Content-Type":"application/json"},body:JSON.stringify({requestId:batch.requestId,items:batch.items})});if(!res.ok)throw Object.assign(Error(res.status===401?"客户端密钥无效":res.status===403?"密钥未授权采购管理 → 回填":`提交失败 (${res.status})`),{code:`HTTP_${res.status}`,retryable:res.status>=500});return validate(await res.json(),batch.items.length)}catch(e){if(e.name==="AbortError")throw Object.assign(Error("提交超时,可用相同批次标识重试"),{code:"TIMEOUT",retryable:true});throw e}finally{clearTimeout(t)}}
|
||||
async function execute(run){try{
|
||||
if(run.phase==="scanning"){let stalled=0;while(run.entryCount<500){const view=await send(run,"LIST");if(view.loginExpired)throw Object.assign(Error("PDD 登录失效或出现安全验证"),{code:"LOGIN_EXPIRED"});if(view.empty){run.scanComplete=true;break}run.entryCount=Math.max(run.entryCount,view.entryCount);run.scrollY=view.scrollY;if(view.endEvidence){run.scanComplete=true;break}const before=run.entryCount;await send(run,"SCROLL");await delay(500);const after=await send(run,"LIST");if(after.loginExpired)throw Object.assign(Error("PDD 登录失效或出现安全验证"),{code:"LOGIN_EXPIRED"});run.entryCount=Math.max(run.entryCount,after.entryCount);run.scrollY=after.scrollY;if(after.endEvidence){run.scanComplete=true;break}stalled=run.entryCount===before?stalled+1:0;await write(run);if(stalled>=3){run.incompleteReason="未看到列表末尾标记";break}}if(run.entryCount>=500)run.incompleteReason="达到单次 500 条有界扫描上限";run.phase="reading";run.detailState="list";await write(run)}
|
||||
if(run.phase==="reading"){while(run.cursor<run.entryCount){if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});let tab=await chrome.tabs.get(run.tabId);if(isListURL(tab.url)&&run.detailState==="returning"){run.cursor++;run.detailState="list";await write(run);continue}if(isListURL(tab.url)){await send(run,"RESTORE",{scrollY:run.scrollY});run.detailState="opening";await write(run);await send(run,"OPEN_ENTRY",{index:run.cursor});await navigated(run,tab.url);run.detailState="detail";await write(run)}else if(run.detailState==="list"){await chrome.tabs.update(run.tabId,{url:run.listURL});await loaded(run.tabId);continue}if(run.detailState!=="returning"){const d=await send(run,"DETAIL");if(d.loginExpired)throw Object.assign(Error("PDD 登录失效或出现安全验证"),{code:"LOGIN_EXPIRED"});if(!d.ok)throw Error(d.error||"订单详情读取失败");run.rows.push(d.classified);run.stats.read++;run.detailState="returning";await write(run)}await returnList(run);run.cursor++;run.detailState="list";await write(run)}run.phase="aggregating";await write(run)}
|
||||
if(run.phase==="aggregating"){prepare(run);run.phase="submitting";await write(run)}
|
||||
if(run.phase==="submitting"){for(const b of run.batches){if(b.status==="confirmed")continue;if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});b.status="sending";b.attempts++;await write(run);try{b.results=await post(run,b);if(b.results.some(x=>x.retryable)){b.status="retryable";b.error={code:"ITEM_RETRYABLE",message:"服务端返回可重试项"};run.phase="incomplete";run.incompleteReason=b.error.message;await write(run);return}b.status="confirmed";run.results.push(...b.results);run.stats.written+=b.results.filter(x=>x.result==="backfilled"||x.result==="already_backfilled").length}catch(e){b.status=e.retryable?"retryable":"failed";b.error={code:e.code||"SUBMIT_FAILED",message:e.message};run.phase=e.retryable?"incomplete":"failed";run.incompleteReason=e.retryable?e.message:run.incompleteReason;await write(run);return}await write(run)}run.phase=run.scanComplete?"completed":"incomplete";await write(run)}
|
||||
}catch(e){run.phase=e.code==="STOPPED"?"stopped":e.code==="LOGIN_EXPIRED"?"failed":"failed";run.error={code:e.code||"RUN_FAILED",message:e.message};if(e.code==="LOGIN_EXPIRED")run.incompleteReason=e.message;await write(run)}finally{active=null}}
|
||||
function resume(){if(active)return;active=(async()=>{const r=await getRun();if(r&&!Core.terminal(r.phase))await execute(r)})().finally(()=>active=null)}
|
||||
async function start(tabId,frozen,key){const old=await getRun();if(old&&!Core.terminal(old.phase))throw Error("已有回填任务正在运行");const tab=await chrome.tabs.get(tabId);if(!Core.validPddURL(tab.url)||!/\/orders\.html/.test(new URL(tab.url).pathname))throw Error("请在 PDD 我的订单页开始");const run={id:uuid(),tabId,listURL:tab.url,phase:"scanning",frozen:{origin:Core.normalizeOrigin(frozen.origin),days:frozen.days,startedAt:frozen.startedAt},entryCount:0,cursor:0,scrollY:0,rows:[],results:[],batches:[],localErrors:[],stats:{read:0,ready:0,written:0,conflicts:0,skippedNoCG:0},scanComplete:false,incompleteReason:"",stopRequested:false,error:null};await chrome.storage.session.set({[SECRET]:{runId:run.id,origin:run.frozen.origin,clientKey:key}});await chrome.storage.session.set({[STATE]:run});resume();return run}
|
||||
chrome.runtime.onMessage.addListener((m,s,reply)=>{if(m?.source!=="goauto-popup")return;(async()=>{if(!Core.trustedUISender(s,chrome.runtime.id))throw Error("拒绝非扩展界面的控制消息");if(m.type==="STATUS")return{ok:true,run:await getRun()};if(m.type==="START"){if(starting)throw Error("已有回填任务正在启动");starting=true;try{return{ok:true,run:await start(m.tabId,m.frozen,m.clientKey)}}finally{starting=false}}if(m.type==="STOP"){const r=await getRun();if(r&&!Core.terminal(r.phase)){r.stopRequested=true;await write(r)}return{ok:true,run:await getRun()}}if(m.type==="RETRY"){const r=await getRun();if(!r||r.phase!=="incomplete"||!r.batches?.some(b=>b.status==="retryable"))throw Error("没有可重试批次");r.stopRequested=false;r.phase="submitting";await write(r);resume();return{ok:true,run:r}}throw Error("未知命令")})().then(reply).catch(e=>reply({ok:false,error:e.message}));return true});resume();
|
||||
@@ -0,0 +1,9 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict'),c=require('../core');
|
||||
test('requires one unambiguous strict CG suffix',()=>{assert.deepEqual(c.addressSuffixes('A_cg123'),['_cg123']);assert.deepEqual(c.addressSuffixes('_cg123 / _cg456'),['_cg123','_cg456']);assert.equal(c.addressSuffix('_cg01'),'')});
|
||||
test('amount accepts exact value and rejects truncation negative or estimate text',()=>{assert.equal(c.moneyCent('¥22.34'),2234);assert.equal(c.moneyCent('22.345'),null);assert.equal(c.moneyCent('-22.34'),null);assert.equal(c.moneyCent('预估实付 22.34'),null)});
|
||||
test('order number removes only copy tail and rejects prose',()=>{assert.equal(c.normalizeOrderNo('ABC-123 复制'),'ABC-123');assert.equal(c.normalizeOrderNo('ABC-123 其他'),'')});
|
||||
test('classification counts no CG before time validation',()=>{const x=c.classifyDetail({address:'普通地址',orderNo:'A1',submittedAt:'',paidAmount:''});assert.deepEqual([x.kind,x.code],['skip','CG_MISSING'])});
|
||||
test('CG without order number is explicit error',()=>{const x=c.classifyDetail({address:'地址_cg123',orderNo:'',submittedAt:'2026-09-18 10:00',paidAmount:''});assert.equal(x.code,'ORDER_NO_INVALID')});
|
||||
test('aggregation detects CG and reused-order conflicts before dedupe',()=>{const f=(cg,no)=>({kind:'candidate',item:{addressSuffix:cg,pddOrderNo:no,orderSubmittedAt:'2026-09-18T02:00:00.000Z',pddOrderAmountCent:null}}),x=c.aggregate([f('_cg123','A'),f('_cg123','B'),f('_cg456','C'),f('_cg789','C')]);assert.deepEqual(x.cgConflicts,['_cg123']);assert.deepEqual(x.orderConflicts,['C']);assert.equal(x.ready.length,0)});
|
||||
test('batches never exceed fifty',()=>assert.deepEqual(c.batches(Array.from({length:101}),50).map(x=>x.length),[50,50,1]));
|
||||
test('trusted UI sender cannot be PDD content sender',()=>{assert.equal(c.trustedUISender({id:'id',url:'chrome-extension://id/popup.html'},'id'),true);assert.equal(c.trustedUISender({id:'id',url:'https://mobile.yangkeduo.com/orders.html',tab:{}},'id'),false)});
|
||||
@@ -0,0 +1,5 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),{JSDOM}=require('jsdom'),Core=require('../core');
|
||||
function load(name){const d=new JSDOM(fs.readFileSync(path.join(__dirname,'fixtures',name),'utf8'),{url:'https://mobile.yangkeduo.com/orders.html'});global.document=d.window.document;global.window=d.window;global.scrollY=0;global.scrollTo=()=>{};d.window.HTMLElement.prototype.getBoundingClientRect=()=>({width:100,height:20});global.self={GoAutoBackfillCore:Core};global.chrome={runtime:{onMessage:{addListener(){}}}};delete require.cache[require.resolve('../content')];return require('../content')}
|
||||
test('synthetic list exposes one product row, excluding header and payment',()=>{const x=load('list.html');assert.equal(x.entries().length,1);assert.equal(x.entries()[0].className,'product');assert.equal(x.summary().endEvidence,true)});
|
||||
test('synthetic detail parses label siblings, not estimated-payment prose',async()=>{const x=load('detail.html'),f=await x.detail(),r=Core.classifyDetail(f);assert.equal(r.kind,'candidate');assert.equal(r.item.addressSuffix,'_cg123');assert.equal(r.item.pddOrderNo,'ABC-123');assert.equal(r.item.pddOrderAmountCent,2234)});
|
||||
test('detail expands address and order controls and waits for delayed fields',async()=>{const x=load('detail-delayed.html');document.getElementById('address').onclick=()=>setTimeout(()=>document.getElementById('address-fields').innerHTML='<div><span>收货地址</span><span>合成路_cg123</span></div>',150);document.getElementById('order').onclick=()=>setTimeout(()=>document.getElementById('order-fields').innerHTML='<div><span>订单号:</span><span>ABC-123</span><i>复制</i></div><div><span>下单时间:</span><span>2026-09-18 10:00:00</span></div>',300);const r=Core.classifyDetail(await x.detail());assert.equal(r.kind,'candidate');assert.equal(r.item.pddOrderNo,'ABC-123')});
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><body><button id="address">展开收货信息</button><button id="order">展开订单信息</button><section id="address-fields"></section><section id="order-fields"></section></body>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<!doctype html><body><button>展开收货信息</button><section><div><span>收货地址</span><span>合成路_cg123</span></div><div><span>订单号</span><span>ABC-123</span><i>复制</i></div><div><span>下单时间</span><span>2026-09-18 10:00:00</span></div><div><span>实付</span><span>22.34</span></div></section></body>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<!doctype html><body><div class="react-base-list"><div class="synthetic-card"><div class="shop"><img alt="合成店铺"></div><div class="product"><img alt="合成商品"></div><div>实付</div><div>立即付款</div></div></div><p>没有更多了</p></body>
|
||||
@@ -0,0 +1,12 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm'),path=require('node:path'),{webcrypto}=require('node:crypto'),Core=require('../core');
|
||||
const source=fs.readFileSync(path.join(__dirname,'..','service-worker.js'),'utf8');
|
||||
function harness({initial={},content,fetcher,initialURL='https://mobile.yangkeduo.com/orders.html'}={}){const session={...initial},listeners=[];let tabURL=initialURL;const area={async get(k){if(Array.isArray(k))return Object.fromEntries(k.map(x=>[x,session[x]]));return{[k]:session[k]}},async set(v){Object.assign(session,v)},async setAccessLevel(){}};const events=()=>({addListener(){}});const chrome={runtime:{id:'extid',onInstalled:events(),onStartup:events(),onMessage:{addListener(fn){listeners.push(fn)}}},storage:{session:area,local:area},tabs:{async get(){return{id:7,url:tabURL,status:'complete'}},async sendMessage(_id,m){return content?content(m):{ok:true,empty:true,entryCount:0,endEvidence:true,scrollY:0}},async update(_id,v){if(v.url)tabURL=v.url},async goBack(){tabURL='https://mobile.yangkeduo.com/orders.html'}}};const ctx={self:null,chrome,URL,AbortController,setTimeout,clearTimeout,fetch:fetcher||global.fetch,crypto:webcrypto,console,__GOAUTO_TEST_TIMEOUT__:5};ctx.self=ctx;ctx.importScripts=()=>ctx.self.GoAutoBackfillCore=Core;vm.runInNewContext(source,ctx);const send=(m,s={id:'extid',url:'chrome-extension://extid/popup.html'})=>new Promise(resolve=>listeners[0](m,s,resolve));return{session,send}}
|
||||
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
test('rejects content sender, serializes START, and persists stopped terminal',async()=>{const h=harness({content:async()=>new Promise(()=>{})});let r=await h.send({source:'goauto-popup',type:'START',tabId:7,clientKey:'k',frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()}},{id:'extid',url:'https://mobile.yangkeduo.com/orders.html',tab:{}});assert.equal(r.ok,false);r=await h.send({source:'goauto-popup',type:'START',tabId:7,clientKey:'k',frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()}});assert.equal(r.ok,true);const second=await h.send({source:'goauto-popup',type:'START',tabId:7,clientKey:'k2',frozen:{origin:'https://other.test',days:2,startedAt:new Date().toISOString()}});assert.equal(second.ok,false);await h.send({source:'goauto-popup',type:'STOP'});await sleep(120);assert.equal(h.session.orderBackfillRun.phase,'stopped')});
|
||||
test('runtime error persists failed terminal state',async()=>{const h=harness({content:async()=>{throw Error('synthetic failure')}});await h.send({source:'goauto-popup',type:'START',tabId:7,clientKey:'k',frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()}});await sleep(120);assert.equal(h.session.orderBackfillRun.phase,'failed');assert.equal(h.session.orderBackfillRun.error.message,'synthetic failure')});
|
||||
test('restart resumes stable batch with frozen origin/key and validates response',async()=>{let call;const stable='00000000-0000-4000-8000-000000000316',item={addressSuffix:'_cg123',pddOrderNo:'A',orderSubmittedAt:'2026-09-18T02:00:00Z',pddOrderAmountCent:null},run={id:'run1',tabId:7,listURL:'https://mobile.yangkeduo.com/orders.html',phase:'submitting',frozen:{origin:'https://frozen.test',days:2,startedAt:'2026-09-18T03:00:00Z'},batches:[{index:0,requestId:stable,status:'sending',items:[item],results:[],attempts:1}],results:[],rows:[],localErrors:[],stats:{read:1,ready:1,written:0,conflicts:0,skippedNoCG:0},scanComplete:true,stopRequested:false};const h=harness({initial:{orderBackfillRun:run,orderBackfillSecret:{runId:'run1',origin:'https://frozen.test',clientKey:'secret'}},fetcher:async(url,o)=>{call={url,auth:o.headers.Authorization,body:JSON.parse(o.body)};return{ok:true,async json(){return{data:{items:[{index:0,taskId:123,result:'backfilled',code:'BACKFILLED',retryable:false}]}}}}}});await sleep(10);assert.equal(call.url,'https://frozen.test/api/client/v1/purchase-tasks/order-backfill');assert.equal(call.auth,'Bearer secret');assert.equal(call.body.requestId,stable);assert.equal(h.session.orderBackfillRun.phase,'completed')});
|
||||
test('timeout remains retryable and does not confirm batch',async()=>{const item={addressSuffix:'_cg123',pddOrderNo:'A'},run={id:'run2',tabId:7,listURL:'https://mobile.yangkeduo.com/orders.html',phase:'submitting',frozen:{origin:'https://frozen.test',days:2,startedAt:'2026-09-18T03:00:00Z'},batches:[{index:0,requestId:'run2:batch:0',status:'pending',items:[item],results:[],attempts:0}],results:[],rows:[],localErrors:[],stats:{read:1,ready:1,written:0,conflicts:0,skippedNoCG:0},scanComplete:true,stopRequested:false};const h=harness({initial:{orderBackfillRun:run,orderBackfillSecret:{runId:'run2',origin:'https://frozen.test',clientKey:'secret'}},fetcher:(_u,o)=>new Promise((_r,reject)=>o.signal.addEventListener('abort',()=>reject(Object.assign(Error('abort'),{name:'AbortError'}))))});h.session.__unused=1;await sleep(30);assert.notEqual(h.session.orderBackfillRun.batches[0].status,'confirmed')});
|
||||
test('retryable item response remains resumable and unconfirmed',async()=>{const item={addressSuffix:'_cg123',pddOrderNo:'A'},run={id:'run3',tabId:7,listURL:'https://mobile.yangkeduo.com/orders.html',phase:'submitting',frozen:{origin:'https://frozen.test',days:2,startedAt:'2026-09-18T03:00:00Z'},batches:[{index:0,requestId:'00000000-0000-4000-8000-000000000317',status:'pending',items:[item],results:[],attempts:0}],results:[],rows:[],localErrors:[],stats:{read:1,ready:1,written:0,conflicts:0,skippedNoCG:0},scanComplete:true,stopRequested:false};const h=harness({initial:{orderBackfillRun:run,orderBackfillSecret:{runId:'run3',origin:'https://frozen.test',clientKey:'secret'}},fetcher:async()=>({ok:true,async json(){return{data:{items:[{index:0,taskId:123,result:'failed',code:'INTERNAL',retryable:true}]}}}})});await sleep(15);assert.equal(h.session.orderBackfillRun.phase,'incomplete');assert.equal(h.session.orderBackfillRun.batches[0].status,'retryable');assert.equal(h.session.orderBackfillRun.results.length,0)});
|
||||
test('scan compares after lazy load and reaches explicit end evidence',async()=>{let count=1;const h=harness({content:async m=>{if(m.type==='SCROLL'){setTimeout(()=>count++,20);return{ok:true}}if(m.type==='LIST')return{ok:true,entryCount:count,endEvidence:count>=4,scrollY:count*100};return new Promise(()=>{})}});await h.send({source:'goauto-popup',type:'START',tabId:7,clientKey:'k',frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()}});await sleep(3200);assert.equal(h.session.orderBackfillRun.entryCount,4);assert.equal(h.session.orderBackfillRun.scanComplete,true);assert.notEqual(h.session.orderBackfillRun.incompleteReason,'未看到列表末尾标记')});
|
||||
test('reading restart after saved row on list advances cursor without duplicate',async()=>{const saved={kind:'skip',code:'CG_MISSING',item:{}},run={id:'read1',tabId:7,listURL:'https://mobile.yangkeduo.com/orders.html',phase:'reading',detailState:'returning',entryCount:1,cursor:0,scrollY:0,rows:[saved],results:[],batches:[],localErrors:[],stats:{read:1,ready:0,written:0,conflicts:0,skippedNoCG:0},frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()},scanComplete:true,stopRequested:false};const h=harness({initial:{orderBackfillRun:run,orderBackfillSecret:{runId:'read1',origin:'https://admin.test',clientKey:'k'}}});await sleep(20);assert.equal(h.session.orderBackfillRun.cursor,1);assert.equal(h.session.orderBackfillRun.rows.length,1);assert.equal(h.session.orderBackfillRun.phase,'completed')});
|
||||
test('reading restart on detail finishes current row then returns to list',async()=>{const run={id:'read2',tabId:7,listURL:'https://mobile.yangkeduo.com/orders.html',phase:'reading',detailState:'detail',entryCount:1,cursor:0,scrollY:200,rows:[],results:[],batches:[],localErrors:[],stats:{read:0,ready:0,written:0,conflicts:0,skippedNoCG:0},frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()},scanComplete:true,stopRequested:false};const h=harness({initialURL:'https://mobile.yangkeduo.com/order.html',initial:{orderBackfillRun:run,orderBackfillSecret:{runId:'read2',origin:'https://admin.test',clientKey:'k'}},content:async m=>m.type==='DETAIL'?{ok:true,classified:{kind:'skip',code:'CG_MISSING',item:{}}}:{ok:true}});await sleep(1100);assert.equal(h.session.orderBackfillRun.cursor,1);assert.equal(h.session.orderBackfillRun.rows.length,1);assert.equal(h.session.orderBackfillRun.phase,'completed')});
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Project-Profile
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
|
||||
wiki_revision: 3b78360779ae520f1ff9e51be3118a04cd549f51
|
||||
synchronized_at: 2026-09-07T09:27:40Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:21Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 项目档案
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Development-Workflow
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.-
|
||||
wiki_revision: 62ddbe4469740c02ce4a6ca2fd1966a89a79322f
|
||||
synchronized_at: 2026-09-05T07:16:44Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:27Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 开发工作流
|
||||
|
||||
@@ -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: 029dc3e155e1828484c62efd0ac804616fcbc714
|
||||
synchronized_at: 2026-09-11T09:09:07Z
|
||||
wiki_revision: 504e1ffaa2cf1989f55048ac7067d8af50fe3bf3
|
||||
synchronized_at: 2026-09-22T02:56:22Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -460,3 +460,63 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- 有交接时,执行器恢复限定的purchaseEntryContext,最多10次、间隔200ms新读取(捕获耗时另计);连续两次相同PDD Activity且具备既有商品/面板证据才跳过openProduct。空树可有限等待,其他应用/Activity改变/页面风险立即拒绝复用。没有交接时保留原一次判断路径;不把UNKNOWN当成功,不跳过正式精确规格/数量/价格/地址或下单验证。
|
||||
- 私有诊断新增handoffPresent、productReused布尔值,reusePolls整数及reuseReason固定枚举legacy_evidence/page_problem/page_changed/handoff_ready/evidence_timeout。仍绑定task/attempt/device/ruleHash,无敏感文本。
|
||||
- 无Server/Web、共享API或数据库变化,不需重启服务器。只有合成测试验证;原CG107/108日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
|
||||
|
||||
## 人工订单回填金额扩展(#306)
|
||||
|
||||
实现绑定 8c01329(2026-09-18),分支 feat/306-order-paid-amount;待验收、未部署。复用 #241/#242 链路,不改普通采购提交和扫描器导航。
|
||||
|
||||
- Android automation/OrderBackfillPolicy.kt:BackfillDetailReader 在订单内收集唯一“实付”金额,使用 BigDecimal 精确转换为人民币分;BackfillItem 增加可选 pddOrderAmountCent。
|
||||
- Android network/OrderBackfillUpload.kt 与 persistence/TaskHistoryCache.kt:上传可选金额,读取并缓存 Server 确认金额和独立警告;不把成功警告改为任务失败。
|
||||
- Server models/purchase.go:purchase_task.pdd_order_amount_cent 为 nullable BIGINT、非负;与 actual_unit_price_cent 独立。追加迁移 1789800100000_purchase_order_amount.go 使用版本内的局部表模型,仅扩展 purchase_task;历史值 NULL,不回填猜测金额。
|
||||
- Server purchase/order_backfill.go:原有逐条事务/设备与订单号保护内补齐空金额;相同值幂等,异值保留并警告;已 order_created 同号任务也能补写。purchase/admin_query.go 输出金额。
|
||||
- Web views/goauto/purchase-tasks/index.vue:复用现有订单 state-card 显示 CNY 实付价格或“尚未获取”;列表结构不变。
|
||||
- 本次不修改 SYB 客户端或 #305,金额不进入 SYB 请求。
|
||||
|
||||
验证覆盖 Android 解析/载荷/回执缓存、Server 幂等/冲突/旧请求和局部 SQLite 迁移、Web 金额/空值展示;真机端到端、实际 MySQL 迁移和发布须另行授权,不能以本地测试替代。
|
||||
|
||||
## SYB 单号回填持久队列(#305)
|
||||
|
||||
实现绑定 e89de1a(2026-09-18,未部署),仅 Server/Web;Android 复用现有普通结果与人工订单回填。
|
||||
|
||||
- purchase/lifecycle.go、purchase/order_backfill.go → ensureOrderWriteback:与订单事实同事务建立 pending,purchase_task_id 唯一;外部失败不撤销已提交的订单事实。
|
||||
- models/purchase_order_writeback.go:purchase_order_writeback 保存不可变 stock/detail/单号快照、状态、attempt_count、write_started、租约、脱敏错误和完成时间;purchase_order_writeback_command 保存 requestId/输入摘要/逐项接受结果;purchase_order_writeback_lease 保存全局单例租约。追加迁移 1789800200000,不改旧物流字段,不批量排入历史订单。
|
||||
- purchase/order_writeback.go:批量命令事务/幂等/条件校验和 Admin 状态投影;order_writeback_handler.go/router.go 暴露 Admin 补偿;access/purchaser.go 纳入原采购权限启动对账目录。
|
||||
- purchase/order_writeback_worker.go:由 cmd/api/server.go 每数据库启动一个3秒轮询 worker;数据库2分钟单例租约使跨进程及不同任务的相同SYB明细写入串行。写前核对当前任务/关联快照,持久化写入起始标记,并验证租约所有者/剩余时间;不持有数据库事务跨网络请求。失联running恢复只回读,unknown不自动调度;其他同目标未确认写入阻止新写。
|
||||
- sybclient/order_number.go:一次 POST updateDetailPurchaseCode,不跟随重定向、不使用读接口重试策略。会话仅从现有 SessionStore 恢复,不在 worker 自动登录/OCR;外部原始错误不写入任务原因。
|
||||
- Web purchase-tasks API/页面:共享勾选、按动作筛选、独立状态列/详情及逐项接受结果;付款和物流流程保持原样。
|
||||
- 验证入口:go test ./app/goauto/purchase ./app/goauto/sybclient ./app/goauto/access ./app/goauto/migrations ./cmd/migrate/migration/version-local;Web tests/e2e/purchase-order-writeback.spec.ts。测试只使用隔离SQLite和fake/httptest,不代表MySQL多实例或真实SYB验收。
|
||||
|
||||
### 会话类失败自动重试(#330)
|
||||
|
||||
- purchase/order_writeback_worker.go:restoreOrderWritebackClient 在 ImportCookiesJSON 后调用 sybclient.CheckSession,UserID<=0 显式判不可用;finishSessionUnavailable 复用 lease_expires_at 作为退避到期时间(maxSessionRetryAttempts=6,sessionRetryBackoff 5/10/15/30/30m),领取条件增加 failed+SYB_SESSION_UNAVAILABLE+到期+未达上限。无迁移。
|
||||
- purchase/order_writeback.go:会话类失败的 CanSubmit 不受退避租约限制;手工重新提交 attempt_count 置 0。
|
||||
- 验证:go test ./app/goauto/purchase(含 httptest 模拟 /am/user/get 与断言 syb_session 未删除)。
|
||||
|
||||
## Chrome PDD 订单回填扩展(#316)
|
||||
|
||||
- `chrome-extension/` 是独立 Manifest V3 交付单元:popup 只负责配置、启动/停止、状态轮询和逐项结果;content script 只在 `mobile.yangkeduo.com` 的隔离世界中按可见 DOM 串行读取;service worker 负责持久运行状态、聚合冲突和分批提交。
|
||||
- 列表遍历限定虚拟列表订单卡片的商品行,排除店铺头和支付、确认收货、退款、售后、取消、更多等动作区;详情只展开收货/订单信息。未看到明确列表末尾证据时必须标记未完整扫描,不得宣称全量。
|
||||
- 客户端密钥与冻结的 Admin Origin 只保存在扩展可信上下文;content script 无权读取。请求禁用 Cookie 与重定向,批次最多 50 条,稳定 UUID 可在 service worker 重启或超时后重放;已确认批次不重复计数。
|
||||
- 服务端 `POST /api/client/v1/purchase-tasks/order-backfill` 复用 Agent 回填事务校验,但使用独立 client principal,不伪造 Device Token 或 deviceId。Agent 路径仍严格保持设备归属校验及失败后按设备范围回读。
|
||||
|
||||
## 下单结果读取及应付总额链路(#325)
|
||||
|
||||
分支 fix/325-order-result,2026-09-19,尚未发布及真实下单验收。PurchaseLiveAutomation.readOrderResult 采用25次支付页返回后采样、首次待付款页独立30次采样/最多4次按需滑动;原过渡60次与总90次采样上限保留有界终止。parsePayableAmount只从本次只读标签集合提取明确应付总额。
|
||||
|
||||
数据链路:PurchaseOrderEvidence.pddOrderAmountCent → PurchaseExecutionOutcome → service/PurchaseResultPayload.kt → purchase.ResultRequest → lifecycle.go 的 order_created 分支 → 既有 purchase_task.pdd_order_amount_cent。重启只读核单也沿用同一可选字段。复用已有结果Outbox、幂等哈希和订单号唯一性;不新增表、配置、任务或SYB金额字段。未修改我的订单回填、Chrome扩展和Web,不能将旧入口的实付含义描述为本次已改成应付。
|
||||
|
||||
## Admin 订单资料补全与一致回填资格(#326)
|
||||
|
||||
实现3a2472d(2026-09-19),Server/Web,未合并发布;无迁移、无Android/插件改动。
|
||||
|
||||
- purchase/types.go ManualRequest 增加可选 pddOrderAmountCent;manual.go ResolveUnknown 复用既有行锁事务和模型单号保护,扩展为未知结果确认或已创建订单补缺。仅此操作启用新增重放内容校验和事务内 ensureOrderWriteback,其他人工付款/取消/授权流程不新增入队副作用。
|
||||
- order_writeback.go 的任务资格去掉 order_created 限制;validOrderWritebackTarget 统一有效 stock/detail 边界。OrderWritebackViews 一次批量读取关联而非逐行查询,RequestOrderWriteback 和 worker 执行前再次检查。队列结构、租约、外部写入协议不变。
|
||||
- web/src/views/goauto/purchase-tasks/index.vue 沿用原详情/弹窗/resolve-unknown API,新增缺字段补录入口、整数分转换、完整性展示及失败保留输入,不增加数据库状态或新的权限资源。旧未知结果请求不带金额继续兼容。
|
||||
- 回归:server go test -p 1 ./app/goauto/purchase ./app/goauto/clientapi ./app/goauto/sybclient;Web 生产构建及 tests/e2e/purchase-order-writeback.spec.ts 的6项隔离浏览器测试。真实SYB接口用fake/httptest替代,未执行付款、采购或线上写入。
|
||||
|
||||
## 规格面板识别回退与选项去重(#331/#332)
|
||||
|
||||
- `automation/PddProductDetailCollector.kt`:`SpecPanelType.REQUIRED_EVIDENCE` 回退分支(地址入口/支付入口/唯一数量输入框 + 同一面板容器 + ≥1 辅助证据);`ParsedPddScreen` 增加 `hasAddressEntry`、`hasPaymentEntry`、`hasQuantityInput`、`hasQuantityAdjustControls`、`quantityInputCount` 与 `requiredEvidenceSummary()`;`SpecPanelStabilityPolicy`(等待上限 5000ms,地址返回采样 200ms);规格值按选项块合并(`optionBlock`)。
|
||||
- `automation/PurchaseRehearsalExecutor.kt`:`waitForSpecPanel` 对回退类型要求连续两次结构一致;`selectionFailureEvidence` 选中失败诊断。
|
||||
- `automation/PurchaseLiveAutomation.kt`:`waitForSettledPanelAfterAddressSave` 保存地址后稳定等待(不按返回键);`finalSubmitTargets` 拒绝零尺寸文字/容器;新增构造参数 `panelDiagnostic`(仅布尔/计数)。
|
||||
- 测试:`SpecPanelRecognitionTest`、`SpecOptionDedupTest`、`SpecPanelFixtures`(脱敏构造样本)。
|
||||
|
||||
@@ -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: d8aeff02fba57cba0b53fb22cdb524fd5cc0cb3e
|
||||
synchronized_at: 2026-09-11T09:09:16Z
|
||||
wiki_revision: 7bb3997cfbe96343f63d4f6ef80201146c27c142
|
||||
synchronized_at: 2026-09-22T02:56:27Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -66,7 +66,7 @@ synchronized_at: 2026-09-11T09:09:16Z
|
||||
- Agent 侧回填为**人工触发的只读扫描**(#242):入口在采购记录页搜索按钮右侧,输入天数(默认 2)后确认启动。天数口径为滚动 N×24 小时,基准是页面读到的下单时间。采集、采购、回填三者互斥,复用既有任务互斥锁并加原子防重入,忙碌时拒绝启动而不排队、不抢占。
|
||||
- 回填扫描不假设订单列表有序:只有连续观察到至少 5 单且下单时间严格递减、期间无缺失时间时,才允许「超过指定时间即停止」;一旦出现时间回升或缺失,改为扫至内部上限并明确标记「未完整扫描」,不得把不完整结果显示为已扫完。内部上限用于限制设备占用,不作为用户可配置的门禁。
|
||||
- 回填扫描全程只读:确认收货、申请退款、催发货、去支付、立即支付、提交订单、付款、退款、取消订单、再次购买、删除订单永久排除为点击目标;点击目标必须自身可点、子树不含上述词,且不与任何含上述词的可见控件几何重叠;不得按固定坐标盲点。遇登录、验证码或风控立即停止并报告。
|
||||
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号与可选下单时间。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
|
||||
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号、可选下单时间,以及 #306 的可选实付金额。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
|
||||
- 已失败、已取消或演练完成的旧采购任务不单独占用主阶段;当前数据仍满足条件时恢复显示“可创建采购”,旧任务继续在采购管理留痕和按既有规则处理。
|
||||
- 未选择处理阶段时,商品列表仍先返回,当前页阶段和采购准备继续异步批量读取且不调用 AI Provider;选择阶段筛选时,服务端必须先对完整查询结果派生并筛选阶段,再计算总数和分页,不能只过滤当前页。
|
||||
|
||||
@@ -332,7 +332,7 @@ synchronized_at: 2026-09-11T09:09:16Z
|
||||
- 一个 PDD 商品可能被多个虾皮商品共用,因此规格匹配按受影响的每个虾皮商品分项记录。主表只表达总体进度;Agent 展示和“继续采购”资格必须读取当前任务对应虾皮商品的分项状态。
|
||||
- 选错替代商品时,连续执行 B→C 不等于撤销 A→B,因为 B 可能还关联其他虾皮商品。正确纠错语义是把原 A→B 记录置为 `superseded`,再建立 A→C,并只处理原记录分项中冻结的影响集合。
|
||||
- 创建请求按 `create_request_id` 幂等;重放时源商品、替代商品、来源类型、来源任务、采集证据和发起设备必须一致,否则返回幂等冲突,不能静默覆盖。
|
||||
- `created_by_device_id` 只代表 Agent 设备。当前系统没有设备到采购员账号的绑定,多人多机场景若需要个人责任追踪,必须另建工单实现设备绑定操作员。
|
||||
- `created_by_device_id` 只代表 Agent 设备。设备可由管理员绑定到一个采购员账号;一个采购员可拥有多台设备,一台设备最多归属一个采购员,也允许暂不归属。SYB 商品一键关联/替换按当前采购员选择的归属设备读取该设备最新临时采集,不使用其他账号或其他设备的全局最新记录。
|
||||
|
||||
|
||||
## PDD 商品替换生效与规格匹配(#131)
|
||||
@@ -610,3 +610,73 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- 有交接时,执行器恢复限定的purchaseEntryContext,最多10次、间隔200ms新读取(捕获耗时另计);连续两次相同PDD Activity且具备既有商品/面板证据才跳过openProduct。空树可有限等待,其他应用/Activity改变/页面风险立即拒绝复用。没有交接时保留原一次判断路径;不把UNKNOWN当成功,不跳过正式精确规格/数量/价格/地址或下单验证。
|
||||
- 私有诊断新增handoffPresent、productReused布尔值,reusePolls整数及reuseReason固定枚举legacy_evidence/page_problem/page_changed/handoff_ready/evidence_timeout。仍绑定task/attempt/device/ruleHash,无敏感文本。
|
||||
- 无Server/Web、共享API或数据库变化,不需重启服务器。只有合成测试验证;原CG107/108日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
|
||||
|
||||
## PDD 订单实付价格(#306)
|
||||
|
||||
实现绑定 8c01329(2026-09-18),待验收,尚未部署。人工“我的订单”回填除订单号和下单时间外,可提交唯一、合法的“实付”价格。它是订单展示总额,人民币分,独立存为 purchase_task.pdd_order_amount_cent;actual_unit_price_cent 仍是下单前观察的商品单价,二者不互相推算。
|
||||
|
||||
不判断是否付款,不改变人工支付复核状态。没看到实付、格式异常或多个不同实付值时,仅跳过金额,订单号和时间照常按原规则回填。采购员可稍后再点既有“回填”,为空金额补写;已有相同值幂等,不同值保留旧值并返回金额冲突提示,不撤销正确订单事实。只允许当前设备自己的、原有允许状态的任务;不新增 attempt 或采购动作。
|
||||
|
||||
金额仅保存 Admin;#305 订单号回写 SYB 的数据流不得使用该字段或将其映射为 cost,金额缺失/冲突不得成为订单号回写的前置条件。本单不代表 #305 已实现。Admin 现有订单详情卡片增加“实付价格”,缺失显示“尚未获取”,不新增列表列、入口或支付动作。安全点击集合及不保存原始控件树/个人信息的规则不变。
|
||||
|
||||
## PDD 订单号自动回填 SYB(#305)
|
||||
|
||||
实现绑定 e89de1a(2026-09-18),代码与隔离测试已完成,真实迁移/发布/SYB写入尚未验证。以下补充 #306 的单号后续流程,不改变其金额规则。
|
||||
|
||||
正式 SYB 订单采购取得 PDD 单号(普通结果或人工遍历回填)后,在订单事实的同一个数据库事务中保存独立回填记录,后台异步写入对应 stock_id + detail_id。已人工回填相同单号也幂等确保记录存在;不重新采购,不等待支付复核。备货、演练、未取得单号或关联失效不回填;不自动扫描历史单据,历史补偿由采购管理勾选发起。
|
||||
|
||||
写前精确回读唯一目标明细:同单号且 purchasePlatform=pdd 直接成功;非空不同单号/不兼容平台标记 conflict,不覆盖。远端为空才允许一次写请求,写后回读确认才成功。未知结果不得自动重复写;重启恢复的 running 只回读,不能因租约过期盲重发。失败/unknown 可人工补偿,仍先回读,且在途租约未到期时不能再发起。
|
||||
|
||||
订单回填状态独立于采购成功、支付复核及物流 writeback_status。实付金额只存 Admin,SYB cost=0 为接口固定参数,不以金额推断付款。SYB 接口会同时更新采购状态/平台/时间,不能视作纯展示修改。
|
||||
|
||||
采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
|
||||
### SYB 会话类失败的有界自动重试(#330)
|
||||
|
||||
实现 01510a8/08b7095(2026-09-21,已合并 main,未部署、未生产验证)。回填 worker 从缓存会话恢复客户端后调用 SYB 会话校验;会话缺失/过期、串号失效、校验网络错误等均记为 `SYB_SESSION_UNAVAILABLE`,error_message 只记录类别和“将自动重试;如持续失败请恢复登录后重试”,不含原始错误。该类失败发生在任何写入之前,最多自动重试 6 次,退避 5/10/15/30/30 分钟(约 90 分钟,大于一个整点同步周期),达上限保持 failed 等人工。会话仍只由每小时 SYB 同步刷新;回填不登录、不 OCR、不删除或写入会话。其他失败码仍不自动重试。退避期内可手工重新回填,手工提交重置尝试次数。历史失败记录不会被自动领取。
|
||||
|
||||
## Agent 回填订单入口兼容(#307)
|
||||
|
||||
- 打开我的订单后最多采样8次,每次间隔1秒,连续两次订单导航结构一致才继续;登录/风控仍立即停止,超时给出明确原因。
|
||||
- 保留全部标签selected=true的原路径。PDD不提供selected属性时,要求订单标题、唯一全部标签以及同排至少两个订单状态标签;仅该安全导航标签允许一次中心手势,不扩大到订单卡片、地址、支付或其他操作。
|
||||
- 本轮主动点击全部后复核订单导航结构;本轮上下文不能跨扫描复用,出现其他已选中订单状态时停止。卡片识别与支付禁令保持不变。
|
||||
- 回填结束/失败通过现有通知与Toast显示脱敏原因;详细结果仍在Agent采购页。本变更不调整服务端回填接口、采集或采购流程。
|
||||
|
||||
## 下单后待付款核单与应付金额(#325)
|
||||
|
||||
实现分支 fix/325-order-result,2026-09-19;未部署、未真机验收。下单点击及不可逆边界不改。已知 PayActivity 允许一次原有 Back,随后从3次放宽为25次200ms采样(约5秒等待,不含页面捕获耗时),持续无证据仍为 PURCHASE_ORDER_PAYMENT_REPEATED。微信恢复、系统选择器、空窗口和陌生应用的既有边界保留。
|
||||
|
||||
过渡阶段最多60次采样。首次识别到待付款证据后单独分配30次采样预算,不被之前绕路消耗,不因文字反复出现重置;核单总采样上限90次。缺少唯一订单号或时间时立即向上滑动读取下方内容,最多4次、手势400ms、每次后等500ms;读全立即停止,已有完整字段不滑动。该待付款读取分支不点击任何控件,也不点击付款。纯支付页不使用该滑动分支;出现可读待付款证据优先于复用的 Activity 名称。按采样间隔约18秒加有限手势/额外等待,实际耗时还含capture等调用;未改变2分钟任务租约或5分钟唤醒锁,也不宣称新增了执行器总体墙钟超时机制。
|
||||
|
||||
应付金额从同一订单的可见标签集合中读取,兼容“应付:,13元”等标点;不跨无关联节点拼接,不读拼单价、优惠或实付。父子重复同金额去重,多值/缺失/无效/溢出省略,不因金额失败丢弃订单号与时间。仅存Admin,不推断支付、不改SYB金额、不修改地址行为。历史回填及界面语义未在本单统一,详见API契约#325补充。
|
||||
|
||||
## 采购订单资料完整性及简化单号回填(#326)
|
||||
|
||||
用户2026-09-19确认内部系统简化流程,原型v1通过。实现3a2472d,尚未发布或真实SYB验收。
|
||||
|
||||
采购任务保存 PDD 订单号、下单时间和订单金额即为资料完整,不检查是否已付款;金额0元有效。复用 pddOrderNo/orderSubmittedAt/pddOrderAmountCent,金额以人民币分存储。order_created 不改义,仍是已创建订单事实;缺金额展示“订单信息待补全”,不能当作采购失败再次下单。页面统一称“订单金额”,其来源可能是#325应付或既有回填实付,不能将历史值统一称已付金额。
|
||||
|
||||
Admin 复用未知结果处理弹窗增加金额输入,并为 live 已创建且缺资料任务提供“补全订单信息”;已有字段只读、缺项可填。保存中禁用提交,失败保留输入;沿用既有权限,不增加审批。非执行中的未知结果人工确认与已有订单资料补全都会事务内保证单号回填记录,不修改支付复核或触发设备操作。
|
||||
|
||||
SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有效明细即可入队,不要求 order_created 状态、时间、金额、付款确认或地址后缀。只有订单号上传SYB;沿用同号成功、异号冲突不覆盖及既有在途防重复规则,不倒扫历史任务。历史补偿仍手动勾选,已成功或在途任务不可重复发起。
|
||||
|
||||
自动下单读取和Admin补录是后续目标来源;本单尚未删除插件或Agent我的订单回填,不能宣称上游地址后缀已取消或历史错误归属已修复。金额缺失不得自动采购,订单号唯一归属保护和永久不付款规则不变。
|
||||
|
||||
## 采购管理已回填 SYB 筛选(#327)
|
||||
|
||||
实现368f2c2,2026-09-19,尚未发布。采购管理现有“状态”下拉框增加“已回填 SYB”,用户选择后点击原查询按钮,按PDD单号回填队列成功记录筛选;不是新的采购任务状态,也不表示物流回填成功或已付款。
|
||||
|
||||
筛选后仍展示原采购状态与独立SYB单号回填状态,已成功任务不因查询而允许重复回填。清空或重置恢复原列表,查询回到第一页,其他条件继续叠加。沿用既有下拉框、空/加载/失败状态及权限,不新增页面、审批、数据字段或外部动作。
|
||||
|
||||
## 规格面板识别与选项去重(#331/#332)
|
||||
|
||||
实现 `0edc78a`(#332)、`d8382e6`(#331),2026-09-22 已合并 main 并发布 Server(Android 0.9.60 仅安装到设备 8);真机采购效果未验证。
|
||||
|
||||
- 规格面板识别在原有判定之后新增回退类型 `REQUIRED_EVIDENCE`:**必须**同时存在收货地址入口(打码手机号所在可点击行)、支付方式修改入口(支付别名所在可点击行)和唯一可用的数量输入框,三者须位于同一规格面板容器内;另需至少一项辅助证据(规格选项、已选/请选择摘要、关闭、加减数量、提交订单)。加减按钮不再是必备条件。原有已识别的面板分类不变。
|
||||
- 地址入口只判断存在与否,不读取、不记录、不上传地址内容;诊断只输出布尔值与计数。
|
||||
- 点击规格入口后、保存地址返回后,若面板仅靠回退类型识别,须连续两次采样结构一致才继续,上限 5 秒;等待期间不按返回键,超时明确失败。稳定且明显不是采购面板的页面(如地址列表)沿用原返回路径。
|
||||
- 提交订单点击保持严格并进一步收紧:必须唯一、可点击、文字匹配,且文字节点及其可点击容器尺寸非零。
|
||||
- 规格选项解析:同一选项块(外层可点击块与其内部文字)只计一个值,文字优先取外层块;不做“零差评/快要抢光”等标签清洗。选中确认失败时诊断列出该维度解析值(截断)、选中状态和目标值。
|
||||
- 缺少必备证据时,失败信息附带可读提示,例如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”“未找到收货地址入口:请确认 PDD 已设置默认收货地址”“未找到购买数量输入框”。
|
||||
|
||||
### 采购手机环境前提
|
||||
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: 835494c4a63a1494601658561fde1ab76657be3d
|
||||
synchronized_at: 2026-09-05T07:17:05Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:42Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -335,3 +335,18 @@ adb shell am start -n cn.ilapage.goauto.agent/.MainActivity
|
||||
- APK 文件目录使用 `GOAUTO_AGENT_RELEASE_DIR`;缺省为服务端工作目录下 `var/goauto-agent-releases`,不得映射成公开静态目录。部署需让 Admin/API 进程对该目录具有创建、写入、读取和删除临时失败文件的权限。
|
||||
- 真机发布验证前确认设备空闲,再由管理员上传一个签名一致、versionCode 更高的 APK并显式设为当前。依次验证启动静默提示、设置页手动检查、下载进度/取消、断网、哈希不一致删除、未知来源引导和系统确认安装。任务执行中全部更新动作必须被阻止,心跳与前台服务继续运行。
|
||||
- 上传或设为当前属于发布动作,安装会改变设备应用版本;没有用户独立授权时不得执行。安装后系统可能关闭无障碍服务,只能由用户在系统设置重新开启。
|
||||
|
||||
## Chrome 订单回填扩展验证(#316)
|
||||
|
||||
从仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
cd chrome-extension
|
||||
npm install
|
||||
npm test
|
||||
node --check service-worker.js
|
||||
node --check content.js
|
||||
node --check popup.js
|
||||
```
|
||||
|
||||
自动化测试只使用合成 DOM 与 Chrome API mock,覆盖列表商品行负向选择、双展开与延迟字段、解析/冲突、启动互斥、停止、错误终态、扫描延迟增长、详情恢复、提交恢复、冻结配置、超时和可重试项。真实 PDD 浏览器点击、扩展安装和真实 Admin 上传不属于自动化验证,必须取得独立授权并在结果中明确区分。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Common-Changes
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Common-Changes.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:12Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:50Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 常见修改指南
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
|
||||
synchronized_at: 2026-09-11T09:10:00Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:54Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Product-Requirements-Overview
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Product-Requirements-Overview.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:20Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求总览与当前 MVP
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:05Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
@@ -819,6 +827,158 @@ Content-Type: application/json
|
||||
- Admin 的 BatchRetry 接口和行为不变。相同 `requestId` 重放返回同一新任务;不同 requestId 再点由最新任务门禁拒绝。
|
||||
|
||||
|
||||
## SYB 商品 PDD 图搜采集(#275~#280)
|
||||
|
||||
图搜任务**就是采集任务**:同一张 `collection_task` 表、`source = 'image_search'`、同一个
|
||||
`/api/agent/v1/tasks/*` 端点。与 `agent_current_page` 的唯一区别是**谁打开那个商品页**——
|
||||
后者由采购员手动打开,图搜由 Agent 用 PDD 的拍照搜索找到并打开。打开之后的链路
|
||||
(分享链接 → goods_id → 身份回填 → 采集 → 提交结果)完全复用,没有新的采集接口。
|
||||
|
||||
### 管理端:批量创建
|
||||
|
||||
```http
|
||||
POST /api/admin/v1/collection-tasks/image-search/batch
|
||||
Content-Type: application/json
|
||||
|
||||
{"requestId":"<uuid>","sybProductIds":[1,2,3],"ruleId":3,"deviceId":null,"overwriteLinked":false}
|
||||
```
|
||||
|
||||
- **按 `shopee_product` 去重**:多条共用同一张参考图的 SYB 明细合并为一个任务。请求最多
|
||||
100 个 `sybProductIds`。
|
||||
- **批次上限作用于去重后的任务数**,不是入参明细数,上限 50(约 17~33 分钟设备占用,
|
||||
按单次图搜 20~40 秒估算)。超限整批拒绝,不部分创建。该值是服务端常量,**不可由环境
|
||||
变量覆盖**:确认对话框必须在提交前禁用按钮,前端持有同一个数字,可覆盖只会让两侧静默
|
||||
不一致。改动时两侧必须同改。
|
||||
- `deviceId` 可空,为空由具备能力的空闲设备领取。`overwriteLinked` 为 false 时已关联 PDD
|
||||
的蝦皮商品返回 `IMAGE_SEARCH_ALREADY_LINKED` 并跳过。
|
||||
- 响应逐条返回 `shopeeProductId` / `sybProductIds` / `taskId` 或 `code` + `message`。
|
||||
|
||||
### 设备能力
|
||||
|
||||
图搜任务要求设备声明 `pdd.image-search.v1`。不具备该能力的设备在领取阶段跳过图搜任务,
|
||||
不会失败。
|
||||
|
||||
### 任务下发
|
||||
|
||||
`GET /api/agent/v1/tasks/next` 与 `claim` 返回的载荷结构对所有来源一致,图搜任务额外携带
|
||||
`imageSearch`:
|
||||
|
||||
```json
|
||||
{"imageUrl":"https://...","mediaType":"image/jpeg","sizeBytes":81234,"sha256":"<64 位小写 hex>"}
|
||||
```
|
||||
|
||||
- **只下发这四个字段。** 关联关系、参考价、价格倍数、去重明细等快照留在服务端的
|
||||
`image_search_snapshot`,不下发给 Agent。
|
||||
- 服务端不下发图片字节;Agent 自行下载 `imageUrl` 并按 `sizeBytes` 与 `sha256` 校验。
|
||||
参考图上限 10MB。
|
||||
- 身份回填前 `pddProductId` 为 `null`,`urlSnapshot` 与 `goodsIdSnapshot` 为空字符串,
|
||||
与 `agent_current_page` 相同。
|
||||
|
||||
### 身份回填
|
||||
|
||||
图搜复用 `POST /api/agent/v1/current-page-collection-tasks/{taskId}/identify`,该接口已同时
|
||||
接受 `agent_current_page` 与 `image_search` 两种来源。
|
||||
|
||||
- `[已核对]` 该接口要求任务已绑定 `device_id`。批量创建允许 `deviceId` 为空,但
|
||||
`Claim` 在事务中写入 `device_id = <领取设备>`,因此 Agent 调用 identify 时设备必定已绑定。
|
||||
- 回填之后的结果提交、失败上报、reset 重采与 `agent_current_page` 完全一致。
|
||||
**`ruleSnapshot` 结构与普通采集任务同构**,图搜不引入第二种规则形态。
|
||||
|
||||
### 结果自动关联与价格兜底
|
||||
|
||||
任务完成后服务端尝试建立 shopee↔PDD 关联,条件全部满足才写入:
|
||||
|
||||
- 任务 `source = 'image_search'`、状态 `completed`、已回填 `pddProductId`;
|
||||
- 目标蝦皮商品 `pdd_product_id IS NULL AND image_search_linked = false`(CAS 谓词:Agent
|
||||
执行期间发生的人工改动胜出,不被覆盖);
|
||||
- 蝦皮商品币种为 CNY。**非 CNY 一律不自动关联**,快照记 `priceGuardSkipped` 与
|
||||
`CROSS_CURRENCY_TWD_PDD_CNY`,留给人工复核;
|
||||
- 候选价与 `syb_product.unit_price_cent` 的偏离在 `maxPriceRatio`(1~100 的整数)之内。
|
||||
|
||||
自动建立的关联置 `shopee_product.image_search_linked = true`,含义是**「机器找的,没人核过」**。
|
||||
采购员在虾皮商品页手动关联(`LinkPDD`)时置回 false。该标记只读地出现在批量采购预览
|
||||
(`BatchPreviewItem.imageSearchLinked`)与采购任务列表,**不参与任何采购门禁、资格判定或
|
||||
下单条件**。
|
||||
|
||||
### 调度:采集 > 图搜
|
||||
|
||||
图搜任务在领取队列中垫底,由 `task.Service.Next` 的 `ORDER BY` 实现:
|
||||
|
||||
```sql
|
||||
-- 共享候选池
|
||||
CASE WHEN device_id IS NULL THEN 1 ELSE 0 END,
|
||||
CASE WHEN source = 'image_search' THEN 1 ELSE 0 END,
|
||||
created_at ASC, id ASC
|
||||
```
|
||||
|
||||
- `[必须]` 该排序只能落在服务端。图搜任务是同一端点下发的普通采集任务,Agent 调用
|
||||
`next` 时服务端已经选好了行,客户端无从推翻。
|
||||
- `[必须]` 来源降级排在 `device_id` **之后**:指派设备是人的显式选择,必须继续优先于来源
|
||||
降级,否则未指派的采集任务会抢在运营明确路由到本机的图搜任务前面。
|
||||
- 采购任务不需要此处排序:它们在独立表、独立端点,Agent 已在检查采集之前处理。
|
||||
|
||||
### 错误码
|
||||
|
||||
管理端(批量创建):
|
||||
|
||||
| 错误码 | 含义 |
|
||||
|---|---|
|
||||
| `IMAGE_SEARCH_BATCH_TOO_LARGE` | 去重后任务数超过单批上限 |
|
||||
| `IMAGE_SEARCH_ALREADY_LINKED` | 蝦皮商品已关联 PDD,逐条跳过 |
|
||||
| `IMAGE_SEARCH_TASK_ACTIVE` | 该蝦皮商品已有未结束的图搜任务 |
|
||||
| `IMAGE_SEARCH_IMAGE_INVALID` | 参考图下载失败、格式不支持或超过限制 |
|
||||
| `IMAGE_SEARCH_INPUT_CHANGED` | 关联、参考图、价格或币种在请求期间变化 |
|
||||
| `IMAGE_SEARCH_REQUEST_CONFLICT` | 同一 `requestId` 的参数已变化 |
|
||||
| `IMAGE_SEARCH_CONFIG_INVALID` | 价格倍数配置不是 1~100 的整数 |
|
||||
| `IMAGE_SEARCH_SNAPSHOT_INVALID` | 任务快照无效 |
|
||||
|
||||
Agent 上报(`POST /api/agent/v1/tasks/{taskId}/fail`):
|
||||
|
||||
| 错误码 | 含义 |
|
||||
|---|---|
|
||||
| `IMAGE_SEARCH_PERMISSION_REQUIRED` | 未授予相册权限 |
|
||||
| `IMAGE_SEARCH_ASSET_INVALID` | 参考图下载、校验或写入相册失败 |
|
||||
| `IMAGE_SEARCH_ASSET_NOT_LATEST` | 相册中存在更新的图片或视频,无法确认会选中我们准备的图 |
|
||||
| `IMAGE_SEARCH_ENTRY_NOT_FOUND` | 找不到唯一可点的拍照搜索入口,或归位失败 |
|
||||
| `IMAGE_SEARCH_NO_CANDIDATES` | 结果页没有可用候选(含全部为广告) |
|
||||
| `IMAGE_SEARCH_AUTOMATION_UNAVAILABLE` | Agent 版本尚未接入图搜自动化 |
|
||||
|
||||
### Agent 执行边界
|
||||
|
||||
- 只使用无障碍树,**禁止 OCR / VLM**。
|
||||
- 相册权限:Android 13+ `READ_MEDIA_IMAGES`,10~12 `READ_EXTERNAL_STORAGE`
|
||||
(`maxSdkVersion=32`)。这是本功能新增的权限,既有设备升级后需重新授权。
|
||||
- 参考图写入设备相册后,Agent 先确认它是相册中**最新的一项(图片与视频一起比较)**,
|
||||
再点击网格第一格;不是最新一项时以 `IMAGE_SEARCH_ASSET_NOT_LATEST` 明确失败,
|
||||
不往后找、不猜测。任务结束(成功或失败)都删除本次写入的图片。
|
||||
- 归位状态机可从商品详情、规格弹层、订单确认页、结果页、重试弹窗回到图搜入口,上限
|
||||
50 次动作。**订单确认页只执行返回**;支付与下单文案仅作只读识别信号,任何情况下都不是
|
||||
点击目标。
|
||||
- 结果页默认打开第一个候选,跳过带「广告」标记的卡片。
|
||||
- 「重新采集」对图搜任务的语义是**重采同一个商品**,不重新搜索;搜错了由采购员在管理端
|
||||
手动改关联(该动作同时清除图搜标记)。
|
||||
|
||||
### PDD 界面判据(2026-09-15 真机核对,设备 1080×2354)
|
||||
|
||||
常量集中在 `PinduoduoImageSearchCriteria`。以下为核对结论,PDD 版本更新后需要重新 dump:
|
||||
|
||||
| 位置 | 判据 | 状态 |
|
||||
|---|---|---|
|
||||
| 首页入口 | `content-desc='拍照搜索'` 且 **clickable** 的唯一节点,实测 `[941,161][1080,228]` | 命中 |
|
||||
| 图搜页 | `我的相册` + `最近搜索` + `历史浏览` + `点击拍照` | 命中 |
|
||||
| 选图网格 | `最近项目` 锚点下方 4 列等宽网格,每格 267px,屏宽 1080,容差 ±24 | 命中 |
|
||||
| 结果页 | `搜图片同款` 且排序控件 ≥ 3(实测 综合/销量/价格/品牌) | 命中 |
|
||||
| 重试弹窗 | `请对准商品或码,保持手机稳定` + `取消` + `再试一次` | **未验证** |
|
||||
|
||||
`[必须]` 入口判定必须同时要求 clickable:个人中心 tab 的**根节点**也带
|
||||
`content-desc='拍照搜索'`,覆盖全屏且不可点,而 `visibleTexts()` 会把 `contentDescription`
|
||||
当文本收集。只按文案匹配会把个人中心判成首页,随后几何兜底会点到该页顶部最靠右的可点
|
||||
节点——实测那是「设置」按钮。
|
||||
|
||||
已知偏差:取景提示真机为「对准商品/条形码/二维码,自动识别」,历史常量
|
||||
`即可进行自动识别` 不命中,靠 `点击拍照` 兜住。相册网格同时包含视频(实测见到带时长的
|
||||
格子),这是上面「最新一项须跨图片与视频比较」的由来。
|
||||
|
||||
## 采集颜色图片上传(#133)
|
||||
|
||||
结构化采集结果成功提交后,Agent 可为任务已采集的颜色逐张调用:
|
||||
@@ -1169,3 +1329,73 @@ requestId 沿用 UUID 约定,不增加批次表或全局幂等缓存。既有
|
||||
- Agent 在当前PDD规格面板重新读取候选并提交同一采购任务的 `spec_rematch_completed` 结果;Server复用现有确定性优先/必要时AI匹配,结果必须属于当次候选。匹配成功后同task重新领取正式attempt,Agent沿用当前页面继续正式规格、数量、价格、地址和下单校验,不新建任务、不重新打开浏览器链接;页面证据失效时仍按原安全路径处理。
|
||||
- 每个采购任务最多一次;结果attempt和服务端状态原子记录,旧决策指针清除后只允许最新attempt固化新决策。重复请求、无资格、已有不可逆证据或旧attempt覆盖均拒绝;不新增数据库列,依赖现有attempt result_type审计。
|
||||
- 新共享字段 `specRematchAllowed` 和结果类型仅供现有Agent采购接口;旧Agent缺省false,旧 `spec_probe_completed` 重复探测拒绝规则不变。未改变设备租约、并发、支付禁止和任务重试语义。
|
||||
|
||||
## 人工订单回填的可选实付金额(#306)
|
||||
|
||||
实现绑定:8c01329(2026-09-18,feat/306-order-paid-amount,待验收,尚未合并/部署/执行业务库迁移)。本节补充并覆盖 #241 中“相同订单号一律无写入”的金额相关描述;订单号、时间、设备归属和状态校验保持原契约。
|
||||
|
||||
POST /api/agent/v1/purchase-tasks/order-backfill 的 items 可增加 pddOrderAmountCent:可空/可省略的 int64,单位人民币分,表示订单详情明确“实付”标签的整单金额。缺失或 null 保留原值;负整数局部忽略,不影响订单号和时间回填。字符串、非整数或 int64 溢出属于 JSON 契约错误,仍按既有解码器返回批级 422;Agent 对识别异常只省略字段,不能发送异常值。
|
||||
|
||||
- unknown 首次回填:可同时填充空金额;不会改变支付、物流或 SYB 回写字段。
|
||||
- order_created 且订单号相同:原金额为空时可补写,statusVersion 增加一次;订单号、下单时间不变。result/code 仍为 already_backfilled/ALREADY_BACKFILLED。
|
||||
- 同值或本次缺失金额:幂等,不增加版本;已有不同金额:保留原金额和正确订单事实,成功结果附 warningCode=PURCHASE_BACKFILL_AMOUNT_CONFLICT、warningMessage。不触发重新采购。
|
||||
- 条目响应可增加 pddOrderAmountCent(已提交数据库值)及上述 warning 字段。跨设备/不存在任务仍不返回业务字段。Android 缓存仅采纳服务端确认值,成功 warning 单独保存,不写为任务 errorCode。
|
||||
- Admin 采购任务查询/详情提供同名可选字段,详情按 CNY 展示,空值显示“尚未获取”。旧 Agent 省略字段仍可使用新 Server。
|
||||
|
||||
Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实付:,金额元,(免运费)及中英文冒号/逗号/空格,金额整数或最多两位小数,以十进制定点转换。不使用裸人民币数字、不跨节点拼金额;缺失、无效、溢出或多个不同金额时只省略金额。同一订单跨视口去重,切换订单清空。并不据此判断已付款。
|
||||
|
||||
追加迁移:1789800100000_purchase_order_amount.go,仅扩展 purchase_task 的可空非负 pdd_order_amount_cent;旧行 NULL,不推算单价×数量。先授权迁移并发布新 Server,再升级 Agent;旧 Server 严格拒绝未知字段,不能先装新 Agent 后向旧 Server 发送金额。本单不扩展普通采购 readOrderResult,也不增加 SYB cost 数据流。
|
||||
|
||||
## SYB 采购单号回填(#305)
|
||||
|
||||
实现绑定 e89de1a(2026-09-18,feat/305-syb-order-writeback),已完成代码及隔离测试;未合并、未部署、未执行业务库迁移或真实 SYB 写入。
|
||||
|
||||
- 普通 Agent order_created 结果和人工 order-backfill 首次成功/同单号 already_backfilled 共用事务内 ensureOrderWriteback。仅 live + syb_order + 有效 PDD 单号与 SYB stock/detail 关联时建立唯一 pending 记录(#326 起不限制任务状态);网络写入由事务外 worker 执行。不改变 Agent 请求/响应字段;金额缺失/冲突不阻止单号入队,不依赖 paymentReviewStatus。
|
||||
- POST /api/admin/v1/purchase-tasks/syb-order-writeback:沿用管理员/采购员采购管理权限;body 为 {requestId: UUID, purchaseTaskIds: 正整数数组},1~100条,不允许重复ID。单条补偿也使用本接口。
|
||||
- 返回 HTTP 200、{code:200,data:{items:[{taskId,result,reason?}]}};result 为 pending/succeeded/skipped/conflict。pending 仅表示受理,不代表远端已写入。无资格或不存在的条目独立 skipped,其他条目继续;数据库事务故障整次命令回滚。服务端校验与冲突错误沿用采购错误信封。
|
||||
- requestId 持久幂等:对排序后的 ID 集合取摘要,相同 requestId/集合返回原接受结果,即使后台状态已推进;改变集合返回422。查询当前完成状态应重新GET,不能以重放响应判断最终成功。
|
||||
- Admin 列表/详情 task 新增 orderWriteback={status,reason?,completedAt?,canSubmit}。无队列时符合资格显示 not_started,否则 not_applicable;队列状态 pending/running/succeeded/failed/conflict/unknown。仅未发起、failed/unknown 且任务仍有资格、租约已过期时 canSubmit=true;running/pending/succeeded/conflict 不提供重复发起。
|
||||
- 回填只写 PDD 订单号;cost 固定0,created为空,绝不传 pddOrderAmountCent 或 actualUnitPriceCent。旧 writebackStatus/支付/物流字段不复用,旧物流 paid 规则不扩展到本接口。
|
||||
- 批量勾选取“可采购重试 OR 可单号回填”的并集,两个按钮各自过滤子集;不得把 order_created 任务送入采购重试。冲突只查看,不提供覆盖。unknown 人工补偿仍先回读,成功仅指回读确认相同单号与 pdd 平台。
|
||||
|
||||
追加迁移 1789800200000_purchase_order_writeback.go 建立独立记录、命令和单例租约表。先授权迁移及权限对账后再运行新版 Server/Web;本实现基于 #306,发布时同时核验其金额列迁移依赖。历史记录不在迁移时自动入队。
|
||||
|
||||
## Chrome 客户端采购订单回填(#316)
|
||||
|
||||
`POST /api/client/v1/purchase-tasks/order-backfill`
|
||||
|
||||
- 认证:`Authorization: Bearer <客户端密钥>`;模块 `purchase_tasks`,必须显式具有 action `writeback`。模块 read、通用 write 或 purchase action 均不隐含 writeback;不接受 Admin Cookie/JWT、Device Token、URL 凭据或客户端指定 deviceId。
|
||||
- 请求和逐项响应沿用本文“Agent 采购订单批量回填”契约,`requestId` 为 UUID、每批 1~50 条。客户端先扫描并按 CG 与订单号双向聚合全部冲突,再分批提交;金额缺失省略,不能发送 0 代替。
|
||||
- 客户端入口不要求任务归属某一 Android 设备,但仍复用任务状态、订单号唯一、同批冲突、金额和时间校验。Agent 入口继续要求认证设备归属,两个入口的错误回读范围不得互相扩大。
|
||||
- `order_created` 且订单号相同只补空金额和空下单时间;已有金额或时间不覆盖。补入页面时间时保存 `backfill:page:` 来源标记并返回 `timeSource=page`;历史已有时间且无来源仍返回 `existing_unknown`。
|
||||
- 扩展必须冻结启动时的 Origin、客户端身份和天数窗口;禁止 content script 接触密钥。网络超时或服务端逐项 `retryable=true` 时使用原稳定批次 UUID 重试,只有通过数量、索引和字段完整性校验的响应才确认批次。
|
||||
|
||||
## 下单结果可选应付总额(#325)
|
||||
|
||||
实现分支 fix/325-order-result,2026-09-19;未部署、未真机下单验收。POST /api/agent/v1/purchase-tasks/:taskId/result 的 resultType=order_created 新增可省略/可空 int64 字段 pddOrderAmountCent,人民币分,语义为本次待付款页明确“应付”的订单总额。actualUnitPriceCent 仍为商品单价。仅在唯一订单号和下单时间完整时随成功结果发送,金额缺失/冲突/无效/溢出时 Agent 省略金额,不影响订单事实。服务端拒绝负数或其他 resultType 携带非空金额,字符串/小数/溢出按现有严格 JSON 解码失败。
|
||||
|
||||
服务端只为已成功确认且无订单号占用冲突的任务补写空 pdd_order_amount_cent;省略、null 或已有非空值均不清空/覆盖。字段纳入现有结果哈希,相同 requestId/attempt/载荷幂等,不同载荷拒绝。不改支付状态,不传金额到 SYB。原列已存在,无追加迁移;新 Agent 上报前须先发布此 Server,旧 Server 不接受新结果字段。
|
||||
|
||||
本单不修改 #306 人工订单回填、#316 Chrome 回填、Web“实付价格”显示或历史值:这些路径仍沿用原语义,不能据此宣称全局金额已统一为应付。此节仅定义新增采购结果入口。
|
||||
|
||||
## Admin 订单资料补全与字段型 SYB 入队(#326)
|
||||
|
||||
实现提交 3a2472d,2026-09-19,feat/326-order-information;隔离测试及构建通过,未合并 main、未发布、未执行真实 SYB 写入。
|
||||
|
||||
复用 POST /api/admin/v1/purchase-tasks/:id/resolve-unknown 及其原有管理员/采购员权限:live 的 order_result_unknown 可人工确认订单或取消;live 的 order_created 可补全订单信息,但不能通过此接口改成 cancelled。status=order_created 时沿用 pddOrderNo、orderSubmittedAt,增加可选非负整数分 pddOrderAmountCent,复用原数据库列。订单号非空、无首尾空白/换行且不超过100字符,时间须为有效非零时间;旧客户端省略金额仍接受并保留已有值。新 Admin 补录表单要求金额,0元合法,最多两位小数,转换为整数分后提交。
|
||||
|
||||
已保存非空字段只补缺,不覆盖不同单号、时间或金额;订单号唯一归属仍由原模型保护。相同 requestId/已保存内容重放不重复保存,改变处理结果或已保存字段返回原采购冲突错误。前端网络失败保留输入及相同载荷的 requestId;修改载荷使用新 requestId。复用原审计字段 UnknownResolveRequestID,无新增表或持久化状态。
|
||||
|
||||
资料完整性由订单号、有效下单时间和非空非负订单金额推导,页面显示“信息完整”或“订单信息待补全”。order_created 仍表示已创建订单,缺金额不转 failed,不代表已付款,不重新采购。
|
||||
|
||||
人工确认/补录订单在同一事务中 ensureOrderWriteback;原 Agent order_created 与插件/Agent order-backfill 保持兼容。SYB 回填仅要求 live + syb_order + 非空订单号 + 有效关联 stock/detail;不检查任务状态、时间、金额、支付复核或地址后缀。列表批量读取有效关联,提交与 worker 校验相同目标范围。既有 pending/running/succeeded 不重复入队,conflict 不覆盖,unknown 保留原回读及租约规则。字段补全不恢复已失败队列,失败队列仍通过原“回填 SYB 单号”补偿操作处理。
|
||||
|
||||
仅回填单号,cost=0/created为空;金额、时间只存 Admin。旧物流 paid 候选流程不改,Android 与 Chrome 上传定位契约未改;今后停用插件和 Agent 回填须另行实施。本次无需数据库迁移;先发布新 Server 再发布发送金额字段的新 Web。
|
||||
|
||||
## Admin 采购列表已回填单号筛选(#327)
|
||||
|
||||
实现368f2c2(2026-09-19),feat/327-syb-writeback-filter;测试和构建通过,未合并发布。
|
||||
|
||||
GET /api/admin/v1/purchase-tasks 的既有 status 参数新增查询专用值 syb_writeback_succeeded。该值按独立 purchase_order_writeback.status=succeeded 且 purchase_task_id 对应当前采购任务过滤,不查询旧物流 writeback_status,不新增或改写任务状态。原 status 合法值及其他参数行为保持不变。
|
||||
|
||||
匹配成功回填的历史任务,不额外要求当前采购状态为 order_created;返回的 task.status 仍为真实采购状态。无单号队列及 pending/running/failed/unknown/conflict 不命中。count及分页共用子查询过滤,可与 taskId、executionMode、taskType、sybProductId、shopeeOrderNo、pddOrderNo 组合;status为空恢复不限制状态。权限与返回信封不变,不写入数据、不触发采购或SYB请求。先发布Server再发布新增筛选值的Web,无数据库迁移或Android变更。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Delivery-Issues
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:28Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:09Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 当前 MVP 交付工单索引
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: OnePlus-Real-Device-Acceptance
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:32Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:13Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 一加真机验收记录
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: PDD-Detail-Rule-Migration-Analysis
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/PDD-Detail-Rule-Migration-Analysis.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:37Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:17Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# PDD 商品详情采集规则迁移分析
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: SYB-ERP-Interface-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
|
||||
wiki_revision: f1792b7fe35925480c25b890e85fa5e551744812
|
||||
synchronized_at: 2026-09-11T09:11:21Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:30Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -679,3 +679,15 @@ settings:
|
||||
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
|
||||
|
||||
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
## PDD 采购单号写入(#305)
|
||||
|
||||
实现绑定 e89de1a(2026-09-18,未部署)。协议依据用户本地 update_syb_pdd_order_number.har 单次样本;HAR/真实标识/Cookie不进入源码、Wiki或工单。本节不宣称远端幂等或原子比较写入。
|
||||
|
||||
POST /am/stock/detail/updateDetailPurchaseCode,无JSON body;query:id=<stock.id>、detailId=<details[].id>、code=<PDD单号>、type=pdd、created=""、cost=0。ID必须为正整数,单号非空、无首尾空白/控制换行、最多100字符。cost是抓包确认的固定参数,不是实际金额;不得传Agent实付价格。
|
||||
|
||||
样本成功信封 HTTP200、status=true;随后 listByStock?hist=0 对应明细 purchaseCode 等于提交单号、purchasePlatform=pdd、purchaseStatus=1,purchaseTime由SYB生成。更新存在采购状态/时间副作用。
|
||||
|
||||
客户端每次处理最多一次写请求,并拒绝HTTP重定向重发;超时/连接中断/5xx/损坏响应作为结果未知。无论写响应成功或未知,都须按 stockId + detailId 唯一回读目标;相同单号+pdd才确认为成功,不同已有单号或其他非空平台记冲突且不覆盖。写前为空才写;目标缺失/重复/字段类型异常不得猜测选择。未知且未回读确认时持久化unknown,人工补偿前再次回读,不使用自动写重试。
|
||||
|
||||
GoAuto自身通过持久租约串行,但SYB没有CAS证据,无法原子隔离系统外客户端;不能保证任意迟到请求已终止。真实接口写入需另行明确授权;当前验证仅fake/httptest。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 933eaf6cc02fed843a6898269fdb5f6848ac2a8a
|
||||
synchronized_at: 2026-09-10T01:42:05Z
|
||||
wiki_revision: cc642ebfa65f429100779e40560a59174ab877a0
|
||||
synchronized_at: 2026-09-22T02:56:36Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署与运维
|
||||
@@ -104,3 +104,52 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 建议API写超时至少为 2T+30 秒,且仍满足原最低620秒;代理读/发送超时需留足对应窗口。T=60/180时原620秒足够容纳应用预算;T=600时至少需要1230秒API写超时及适当更长的代理窗口。不得仅设置较大的AI超时却忽略传输层更早截断。
|
||||
- 上述是发布核对要求,不是本单已修改/验证线上代理的结论。超大T带来长连接占用,不能用关闭超时来代替正确预算。新增失败回读不代表必须延长任何线上请求。
|
||||
- 必须配套发布 Server/Web;仅上新版Web连接旧Server时,因缺失派生预算会提示等待时间不可用并停止匹配。原Android不需升级。
|
||||
|
||||
## 本地 Supervisor 与局域网访问
|
||||
|
||||
本机开发/联调环境由 `D:\supervisor\programs\goauto.conf` 分别启动 GoAuto API 与 Admin Web;实例配置只负责调用项目脚本,端口读取 `D:\OPC\goauto\config.yaml`。
|
||||
|
||||
- API 由 `scripts/start-server.ps1` 启动并监听所有网卡的 `ports.server`。
|
||||
- Admin Web 由 `scripts/start-web.ps1` 启动,监听 `0.0.0.0` 的 `ports.web`,因此同一局域网设备可通过 `http://<本机局域网IP>:<web端口>/` 访问。
|
||||
- 浏览器使用同源相对 API;Vite 将 `/api` 与 `/static` 代理到本机 `127.0.0.1:<server端口>`,避免局域网客户端错误访问自身的 `127.0.0.1`。
|
||||
- 修改脚本或配置后,只需重启 Supervisor 的 `goauto-admin-ui`;API 未变化时无需重启 `goauto-admin-api`。若局域网仍无法连接,检查 Windows 防火墙是否允许 `ports.web` 的 TCP 入站。
|
||||
|
||||
## SYB 订单号回填 worker 部署与排错(#305)
|
||||
|
||||
代码绑定 e89de1a(2026-09-18),基于 #306 的07a3817;本节是部署要求,不表示已发布或业务库已迁移。
|
||||
|
||||
1. 另行取得明确目标环境的数据库迁移、权限对账和发布授权。核对 #306 的1789800100000金额列前置;本单追加1789800200000_purchase_order_writeback.go,建立purchase_order_writeback、purchase_order_writeback_command、purchase_order_writeback_lease三表和单例行,不改任务/付款事实,不排入历史回填、不修改定时任务开关。
|
||||
2. 配套构建并发布Server/Web,沿用现有启动权限对账为管理员/采购员登记POST /api/admin/v1/purchase-tasks/syb-order-writeback。不新增角色、菜单或支付门槛。旧常驻实例使用SkipMigration时,仅重启并不能替代授权迁移。
|
||||
3. API启动后每数据库恢复一个3秒轮询的持久队列worker,独立于Admin定时任务列表;只处理已排入pending及租约过期running。正式采购结果、人工订单回填成功/同号回放会排队;历史补偿仅用户勾选发起。部署重启后已有pending会执行真实SYB写入,必须包含在发布授权范围内。
|
||||
4. 全局租约2分钟;会话恢复预算25秒,每次外部读/写20秒(只读客户端重试亦受该context限制),写前再次核验租约。崩溃恢复只回读,不盲目重写;unknown不自动重试,等租约到期再人工核对/补偿。同目标其他未确认写入先解决,不能删除记录规避保护。
|
||||
5. 排错先看采购管理的“SYB 单号回填”状态、脱敏原因、完成时间;需要结构诊断时查询记录的taskId/status/attempt_count/error_code/lease_expires_at,不输出单号、Cookie或完整原始响应。SYB会话失败应先恢复既有登录,再勾选失败任务补偿;按钮受理不等于SYB成功。conflict只核对,不强制覆盖。
|
||||
6. 不执行真实支付;实付金额只存Admin,SYB请求cost固定0。停止新版API可停止新轮询,但不保证已发送远端请求取消;回滚保留追加表和未知状态,不删除记录、不回退采购事实。迁移/多实例MySQL/真实SYB端到端仍需授权验收。
|
||||
|
||||
### #305/#306 线上发布验证(2026-09-18)
|
||||
|
||||
用户授权合并main、迁移并发布线上;main已快进至7e257ca(包含#306及#305),Server/Web从该提交重新构建。此前线上RELEASE记录60c7526;新发布目录为/home/goauto/releases/20260918-7e257ca-305,current已原子切换,goauto.service重启、Nginx校验及reload成功。先前各节“未部署”是对应提交阶段记录,本节为后续发布事实。
|
||||
|
||||
- 迁移前51个版本已应用,唯一缺失为1789800100000和1789800200000;服务器内受限备份/home/goauto/backups/20260918-305/database.sql(目录0700、文件0600),不下载生产数据。执行后53版本齐全,purchase_task.pdd_order_amount_cent为nullable BIGINT,独立三表和单例租约行已建立,历史回填队列0条。
|
||||
- 启动对账已登记新POST接口及purchaser权限,管理员沿用原授权;已登录采购列表返回orderWriteback,空批次请求返回422且不入队。未执行真实订单回填或付款,不将空队列健康验证宣称为SYB端到端验收。
|
||||
- 保持sys_job原启停值不变(任务1/2为1,3/4/5为2),没有更改Cron或主动触发同步。发布前采集/采购运行数均0。
|
||||
- 原20260916目录的static/var与20260914目录互相循环,不能继续通过旧current间接链接。新release分别直接引用已确认真实存在的/home/goauto/releases/20260907-29ba16e-236/static和/home/goauto/releases/20260903-2d6d244-r1/var;不删除或改写历史资源,不修改其他站点Nginx配置。后续清理旧release前必须检查这些被引用的数据目录。
|
||||
- 公网入口http://185.216.248.75:9527首页和10项JS/CSS资源HTTP200,/api/v1/health HTTP200;未认证采购接口业务码401。二进制SHA256=4833d779843f0613455ea2b56cadff6efd88d2b96c13e00cb4423cac02e72cb7;发布后结构日志检查无panic/fatal/1146/1054。
|
||||
- 回滚代码目标保留/home/goauto/releases/20260916-03647dd,追加数据库表/列保留;该旧目录原有static/var循环是已知问题,回退不能宣称资源链自动恢复。不要删除新队列记录或恢复旧数据覆盖后续业务;停止服务亦不保证已发送远端请求取消。
|
||||
|
||||
## Chrome 订单回填扩展交付与运行(#316)
|
||||
|
||||
- 扩展以 `chrome-extension/manifest.json` 为入口,不随 Server 自动部署。解压安装、授予 Admin Origin、创建或修改客户端密钥授权、真实 PDD 遍历及订单回填都必须按目标环境另行授权;发布 Server 不等于安装或启用扩展。
|
||||
- 管理员为密钥显式勾选“采购管理 → 回填(writeback)”。建议 HTTPS;若使用项目既有客户端密钥 HTTP 例外,密钥和订单事实会明文传输,风险边界不扩大到 Cookie、JWT 或 Device Token。
|
||||
- popup 关闭不停止运行;重新打开可查看持久状态。设置在运行时只读。停止会保留已确认批次并阻止后续请求;重启或超时仅以原批次 UUID 重放未确认批次。
|
||||
- 排错只查看阶段、已读、可提交、已写入、冲突、未完整扫描原因、脱敏错误码和批次状态;不得记录客户端密钥、地址全文、订单号、Cookie、原始 HTML 或真实订单载荷。没有明确列表末尾证据时只能报告未完整扫描。
|
||||
|
||||
## 采购手机准备清单(#331)
|
||||
|
||||
采购员配置或更换采购用 Android 手机时逐项确认:
|
||||
1. 拼多多 App 已登录;
|
||||
2. 微信已登录;
|
||||
3. 拼多多已设置默认收货地址;
|
||||
4. 拼多多默认支付方式为微信支付;
|
||||
5. GoAuto Agent 已启动,无障碍服务已开启,Admin 设备列表显示在线并已归属对应采购员账号。
|
||||
|
||||
任一项不满足时采购会明确失败并提示缺失项(如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”);Agent 不代为登录、切换支付方式或填写地址。安装新版 Agent 后需手动启动一次 App,心跳恢复后设备才显示在线。
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Home
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Home
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:29:34Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:18Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# GoAuto 文档中心
|
||||
|
||||
Vendored
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-Template
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-Template.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:41Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:22Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署文档模板
|
||||
|
||||
Vendored
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-Archive-Template
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Task-Archive-Template.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:46Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:26Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
> 本模板只用于用户明确要求的专项历史快照或读取既有归档,不属于标准任务闭环。单次任务的唯一事实来源是 Gitea 工单;不要为了完成普通任务创建本页面,也不要自动导出到 `docs/task/`。
|
||||
|
||||
@@ -97,6 +97,31 @@ function Read-DatabaseConfig {
|
||||
}
|
||||
}
|
||||
|
||||
function Read-ErpGoConfig {
|
||||
param([string]$Path)
|
||||
|
||||
# Optional section. Absent or incomplete means the Shopee spec sync stays
|
||||
# off; the server treats that as "skip", not as an error (#290).
|
||||
$values = @{}
|
||||
$insideSection = $false
|
||||
foreach ($line in Get-Content -LiteralPath $Path) {
|
||||
if ($line -match '^\s*(#.*)?$') {
|
||||
continue
|
||||
}
|
||||
if ($line -match '^erpgo\s*:\s*$') {
|
||||
$insideSection = $true
|
||||
continue
|
||||
}
|
||||
if ($insideSection -and $line -match '^\S') {
|
||||
break
|
||||
}
|
||||
if ($insideSection -and $line -match '^\s+(baseUrl|apikey)\s*:\s*(.*?)\s*$') {
|
||||
$values[$Matches[1]] = ConvertFrom-YamlScalar $Matches[2]
|
||||
}
|
||||
}
|
||||
return $values
|
||||
}
|
||||
|
||||
function Read-PortConfig {
|
||||
param([string]$Path)
|
||||
|
||||
@@ -169,6 +194,7 @@ try {
|
||||
$ConfigPath = [IO.Path]::GetFullPath($ConfigPath)
|
||||
$databaseConfig = Read-DatabaseConfig $ConfigPath
|
||||
$portConfig = Read-PortConfig $ConfigPath
|
||||
$erpgoConfig = Read-ErpGoConfig $ConfigPath
|
||||
|
||||
if (-not $PSBoundParameters.ContainsKey('DatabaseHost')) {
|
||||
$DatabaseHost = $databaseConfig.Host
|
||||
@@ -219,6 +245,14 @@ try {
|
||||
$env:GOAUTO_DB_DRIVER = "mysql"
|
||||
$env:GOAUTO_DB_DSN = "${DatabaseUser}:${plainPassword}@tcp(${DatabaseHost}:${DatabasePort})/${DatabaseName}?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s"
|
||||
$env:GOAUTO_SERVER_PORT = [string]$portConfig.Server
|
||||
# Shopee spec service. The key never reaches the repo: config.yaml is
|
||||
# gitignored and the server only ever reads the environment (#290).
|
||||
if ($erpgoConfig.ContainsKey('baseUrl') -and -not [string]::IsNullOrWhiteSpace([string]$erpgoConfig.baseUrl)) {
|
||||
$env:GOAUTO_ERPGO_BASE_URL = [string]$erpgoConfig.baseUrl
|
||||
}
|
||||
if ($erpgoConfig.ContainsKey('apikey') -and -not [string]::IsNullOrWhiteSpace([string]$erpgoConfig.apikey)) {
|
||||
$env:GOAUTO_ERPGO_APIKEY = [string]$erpgoConfig.apikey
|
||||
}
|
||||
|
||||
Push-Location $serverDirectory
|
||||
try {
|
||||
@@ -272,5 +306,7 @@ finally {
|
||||
Remove-Item Env:GOAUTO_DB_DRIVER -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOAUTO_SERVER_PORT -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOAUTO_CONFIG -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOAUTO_ERPGO_BASE_URL -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOAUTO_ERPGO_APIKEY -ErrorAction SilentlyContinue
|
||||
$plainPassword = $null
|
||||
}
|
||||
|
||||
@@ -168,18 +168,22 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
$env:VUE_APP_BASE_API = "http://127.0.0.1:$($ports.Server)"
|
||||
Write-Host "Starting GoAuto web UI at http://127.0.0.1:$($ports.Web)" -ForegroundColor Green
|
||||
# Keep browser requests same-origin. Vite proxies API/static requests to the
|
||||
# local backend, so a LAN client never tries to call its own 127.0.0.1.
|
||||
$env:VUE_APP_BASE_API = ""
|
||||
$env:VITE_DEV_PROXY_TARGET = "http://127.0.0.1:$($ports.Server)"
|
||||
Write-Host "Starting GoAuto web UI at http://0.0.0.0:$($ports.Web)" -ForegroundColor Green
|
||||
$viteCli = Join-Path $webDirectory "node_modules\vite\bin\vite.js"
|
||||
if (-not (Test-Path -LiteralPath $viteCli -PathType Leaf)) {
|
||||
throw "Vite was not found after dependency installation: $viteCli"
|
||||
}
|
||||
& $nodePath $viteCli --host 127.0.0.1 --port $ports.Web --strictPort
|
||||
& $nodePath $viteCli --host 0.0.0.0 --port $ports.Web --strictPort
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Frontend exited with code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item Env:VUE_APP_BASE_API -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:VITE_DEV_PROXY_TARGET -ErrorAction SilentlyContinue
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
@@ -101,6 +101,8 @@ var AdminAPIs = []APIPermission{
|
||||
{"批量创建图搜采集任务", "/api/admin/v1/collection-tasks/image-search/batch", "POST", true},
|
||||
{"查看采集任务详情", "/api/admin/v1/collection-tasks/:taskId", "GET", true},
|
||||
{"重置采集任务", "/api/admin/v1/collection-tasks/:taskId/reset", "POST", true},
|
||||
{"取消采集任务", "/api/admin/v1/collection-tasks/:taskId/cancel", "POST", true},
|
||||
{"批量取消采集任务", "/api/admin/v1/collection-tasks/batch-cancel", "POST", true},
|
||||
{"删除采集任务", "/api/admin/v1/collection-tasks/:taskId", "DELETE", true},
|
||||
|
||||
{"查看采购任务", "/api/admin/v1/purchase-tasks", "GET", true},
|
||||
@@ -108,6 +110,7 @@ var AdminAPIs = []APIPermission{
|
||||
{"批量 AI 匹配采购规格", "/api/admin/v1/purchase-tasks/batch-spec-match", "POST", true},
|
||||
{"批量创建采购任务", "/api/admin/v1/purchase-tasks/batch", "POST", true},
|
||||
{"批量重试采购任务", "/api/admin/v1/purchase-tasks/batch-retry", "POST", true},
|
||||
{"回填SYB采购单号", "/api/admin/v1/purchase-tasks/syb-order-writeback", "POST", true},
|
||||
{"创建备货采购任务", "/api/admin/v1/purchase-tasks/stock", "POST", true},
|
||||
{"查看采购任务详情", "/api/admin/v1/purchase-tasks/:taskId", "GET", true},
|
||||
{"创建采购任务", "/api/admin/v1/purchase-tasks", "POST", true},
|
||||
|
||||
@@ -2,6 +2,15 @@ package access
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPurchaserMayWritebackOrderNumber(t *testing.T) {
|
||||
for _, permission := range PurchaserAPIs() {
|
||||
if permission.Method == "POST" && permission.Path == "/api/admin/v1/purchase-tasks/syb-order-writeback" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("missing purchaser order number writeback permission")
|
||||
}
|
||||
|
||||
func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, permission := range AdminAPIs {
|
||||
|
||||
@@ -91,6 +91,43 @@ func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
func keyFor(e Endpoint) string { return e.Method + " " + e.Path }
|
||||
|
||||
func TestOrderBackfillRequiresExplicitWritebackAction(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
e := Endpoint{Method: "POST", Path: "/purchase-tasks/order-backfill", Module: "purchase_tasks", Capability: "writeback"}
|
||||
router := gin.New()
|
||||
called := 0
|
||||
router.POST("/api/client/v1"+e.Path, Gate(db, e), func(c *gin.Context) { called++; c.JSON(200, gin.H{"data": "ok"}) })
|
||||
send := func(token string) int {
|
||||
req := httptest.NewRequest("POST", "https://example.test/api/client/v1"+e.Path, strings.NewReader(`{"requestId":"00000000-0000-4000-8000-000000000316","items":[]}`))
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr.Code
|
||||
}
|
||||
if got := send(""); got != 401 {
|
||||
t.Fatalf("missing key status %d", got)
|
||||
}
|
||||
if (clientkey.Key{GrantsJSON: `[{"module":"purchase_tasks","write":true}]`}).Allows("purchase_tasks", "writeback") {
|
||||
t.Fatal("generic write must not imply writeback")
|
||||
}
|
||||
_, writeOnly, err := s.Create(context.Background(), "purchase-only", []clientkey.Grant{{Module: "purchase_tasks", Actions: []string{"purchase"}}}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := send(writeOnly); got != 403 {
|
||||
t.Fatalf("generic write unexpectedly granted writeback: %d", got)
|
||||
}
|
||||
_, authorized, err := s.Create(context.Background(), "writeback", []clientkey.Grant{{Module: "purchase_tasks", Actions: []string{"writeback"}}}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := send(authorized); got != 200 || called != 1 {
|
||||
t.Fatalf("explicit writeback status=%d called=%d", got, called)
|
||||
}
|
||||
}
|
||||
func TestRevocationTransportRedactionAndAudit(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products"}}, 1)
|
||||
|
||||
@@ -105,6 +105,7 @@ func Inventory() []Endpoint {
|
||||
{"POST", "/purchase-tasks/batch", access.ModulePurchaseTasks, "purchase", buy.AdminBatchCreate},
|
||||
{"POST", "/purchase-tasks/batch-retry", access.ModulePurchaseTasks, "purchase", buy.AdminBatchRetry},
|
||||
{"POST", "/purchase-tasks/stock", access.ModulePurchaseTasks, "purchase", buy.AdminCreateStock},
|
||||
{"POST", "/purchase-tasks/order-backfill", access.ModulePurchaseTasks, "writeback", buy.ClientBackfillOrders},
|
||||
{"GET", "/ai-matching-settings", access.ModuleAIMatching, "read", func(c *gin.Context) {
|
||||
db, err := pkg.GetOrm(c)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,14 @@ func (handler Handler) List(context *gin.Context) {
|
||||
writeError(context, internalError(err))
|
||||
return
|
||||
}
|
||||
if role, _ := jwt.ExtractClaims(context)["rolekey"].(string); role != "admin" {
|
||||
id := currentUserID(context)
|
||||
if id > 0 {
|
||||
request.OwnerUserID = &id
|
||||
} else {
|
||||
request.OwnerUserID = new(uint64)
|
||||
}
|
||||
}
|
||||
response, err := NewService(db).List(context.Request.Context(), request)
|
||||
if err != nil {
|
||||
writeError(context, err)
|
||||
@@ -37,6 +46,68 @@ func (handler Handler) List(context *gin.Context) {
|
||||
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": response})
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) uint64 {
|
||||
// #333: go-admin's Authorizator runs on every request with the
|
||||
// IdentityHandler map, which has no "user" entry, so c.Get("userId") is
|
||||
// always 0 there. The JWT "identity" claim is the authenticated user id.
|
||||
switch id := jwt.ExtractClaims(c)["identity"].(type) {
|
||||
case float64:
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
case int:
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
case int64:
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
case uint64:
|
||||
return id
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (handler Handler) Owners(context *gin.Context) {
|
||||
db, err := handler.database(context)
|
||||
if err != nil {
|
||||
writeError(context, internalError(err))
|
||||
return
|
||||
}
|
||||
rows, err := NewService(db).Owners(context.Request.Context())
|
||||
if err != nil {
|
||||
writeError(context, err)
|
||||
return
|
||||
}
|
||||
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": rows})
|
||||
}
|
||||
|
||||
func (handler Handler) SetOwner(context *gin.Context) {
|
||||
deviceID, err := strconv.ParseUint(context.Param("deviceId"), 10, 64)
|
||||
if err != nil || deviceID == 0 {
|
||||
writeError(context, invalidRequest("deviceId 无效"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
OwnerUserID *uint64 `json:"ownerUserId"`
|
||||
}
|
||||
if err := decodeJSON(context, &req); err != nil {
|
||||
writeError(context, invalidRequest("请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
db, err := handler.database(context)
|
||||
if err != nil {
|
||||
writeError(context, internalError(err))
|
||||
return
|
||||
}
|
||||
if err := NewService(db).SetOwner(context.Request.Context(), deviceID, req.OwnerUserID); err != nil {
|
||||
writeError(context, err)
|
||||
return
|
||||
}
|
||||
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": gin.H{"deviceId": deviceID, "ownerUserId": req.OwnerUserID}})
|
||||
}
|
||||
|
||||
func (handler Handler) Register(context *gin.Context) {
|
||||
request, err := decodeRegisterRequest(context)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,10 +13,11 @@ import (
|
||||
)
|
||||
|
||||
type ListRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Name string
|
||||
Status string
|
||||
Page int
|
||||
PageSize int
|
||||
Name string
|
||||
Status string
|
||||
OwnerUserID *uint64
|
||||
}
|
||||
|
||||
type DeviceListItem struct {
|
||||
@@ -36,6 +37,8 @@ type DeviceListItem struct {
|
||||
LastHeartbeatAt *time.Time `json:"lastHeartbeatAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
OwnerUserID *uint64 `json:"ownerUserId"`
|
||||
OwnerName string `json:"ownerName"`
|
||||
}
|
||||
|
||||
type DeviceListResponse struct {
|
||||
@@ -68,6 +71,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (DeviceLi
|
||||
if request.Status != "" {
|
||||
query = query.Where("status = ?", request.Status)
|
||||
}
|
||||
if request.OwnerUserID != nil {
|
||||
query = query.Where("owner_user_id = ?", *request.OwnerUserID)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return DeviceListResponse{}, internalError(err)
|
||||
@@ -100,8 +106,8 @@ func (service *Service) List(ctx context.Context, request ListRequest) (DeviceLi
|
||||
AndroidVersion: record.AndroidVersion, AgentVersion: record.AgentVersion, PDDVersion: record.PDDVersion,
|
||||
Capabilities: capabilities,
|
||||
Status: record.Status, LastHeartbeatAt: record.LastHeartbeatAt,
|
||||
TokenRevoked: record.TokenRevokedAt != nil,
|
||||
CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt,
|
||||
TokenRevoked: record.TokenRevokedAt != nil, OwnerUserID: record.OwnerUserID,
|
||||
CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt,
|
||||
}
|
||||
if taskID, busy := currentTasks[record.ID]; busy {
|
||||
item.Busy = true
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
// #333: mirrors the real go-admin middleware, whose Authorizator sets
|
||||
// userId to 0 on every request while the JWT claims carry the identity.
|
||||
func listDevicesAs(t *testing.T, handler Handler, claims jwt.MapClaims) []DeviceListItem {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.GET("/devices", func(c *gin.Context) {
|
||||
c.Set(jwt.JwtPayloadKey, claims)
|
||||
c.Set("userId", 0)
|
||||
c.Next()
|
||||
}, handler.List)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/devices?page=1&pageSize=20", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data DeviceListResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
return body.Data.Items
|
||||
}
|
||||
|
||||
func seedOwnedDevice(t *testing.T, handler Handler, name string, owner *uint64) models.AgentDevice {
|
||||
t.Helper()
|
||||
device := models.AgentDevice{
|
||||
InstallID: "install-" + name, Name: name, Manufacturer: "test", Model: "test",
|
||||
AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", CapabilitiesJSON: "[]",
|
||||
Status: models.DeviceStatusOnline, TokenDigest: fmt.Sprintf("digest-%s", name),
|
||||
TokenIssuedAt: time.Now(), OwnerUserID: owner,
|
||||
}
|
||||
if err := handler.DB.Create(&device).Error; err != nil {
|
||||
t.Fatalf("create device: %v", err)
|
||||
}
|
||||
return device
|
||||
}
|
||||
|
||||
func deviceNames(items []DeviceListItem) map[string]bool {
|
||||
names := map[string]bool{}
|
||||
for _, item := range items {
|
||||
names[item.Name] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestDeviceListPurchaserSeesOnlyOwnDevicesFromJWTIdentity(t *testing.T) {
|
||||
handler := Handler{DB: openTestDatabase(t)}
|
||||
two, three := uint64(2), uint64(3)
|
||||
seedOwnedDevice(t, handler, "caigou1-phone", &two)
|
||||
seedOwnedDevice(t, handler, "caigou2-phone", &three)
|
||||
seedOwnedDevice(t, handler, "unowned-phone", nil)
|
||||
|
||||
names := deviceNames(listDevicesAs(t, handler, jwt.MapClaims{"rolekey": "purchaser", "identity": float64(3)}))
|
||||
if len(names) != 1 || !names["caigou2-phone"] {
|
||||
t.Fatalf("purchaser 3 should see only own device, got %v", names)
|
||||
}
|
||||
|
||||
all := deviceNames(listDevicesAs(t, handler, jwt.MapClaims{"rolekey": "admin", "identity": float64(1)}))
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("admin should see all devices, got %v", all)
|
||||
}
|
||||
|
||||
none := listDevicesAs(t, handler, jwt.MapClaims{"rolekey": "purchaser"})
|
||||
if len(none) != 0 {
|
||||
t.Fatalf("purchaser without identity must see no devices, got %d", len(none))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DeviceOwnerItem struct {
|
||||
UserID uint64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
NickName string `json:"nickName"`
|
||||
}
|
||||
|
||||
func (service *Service) Owners(ctx context.Context) ([]DeviceOwnerItem, error) {
|
||||
var rows []DeviceOwnerItem
|
||||
if err := service.DB.WithContext(ctx).Table("sys_user u").Select("u.user_id AS user_id, u.username, u.nick_name").Joins("JOIN sys_role r ON r.role_id = u.role_id").Where("u.status <> ? AND r.role_key <> ?", "1", "admin").Order("u.user_id").Scan(&rows).Error; err != nil {
|
||||
return nil, internalError(err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (service *Service) SetOwner(ctx context.Context, deviceID uint64, owner *uint64) error {
|
||||
if deviceID == 0 {
|
||||
return invalidRequest("deviceId 无效")
|
||||
}
|
||||
if owner != nil && *owner == 0 {
|
||||
return invalidRequest("ownerUserId 无效")
|
||||
}
|
||||
if owner != nil {
|
||||
var count int64
|
||||
if err := service.DB.WithContext(ctx).Table("sys_user u").Joins("JOIN sys_role r ON r.role_id = u.role_id").Where("u.user_id = ? AND u.status <> ? AND r.role_key <> ?", *owner, "1", "admin").Count(&count).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if count != 1 {
|
||||
return invalidRequest("采购员不存在或不可分配")
|
||||
}
|
||||
}
|
||||
result := service.DB.WithContext(ctx).Model(&models.AgentDevice{}).Where("id = ?", deviceID).Update("owner_user_id", owner)
|
||||
if result.Error != nil {
|
||||
return internalError(result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseOwnerID(value string) (*uint64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
id, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return nil, fmt.Errorf("ownerUserId 无效")
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
@@ -21,6 +21,8 @@ func InitRouter(engine *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
|
||||
admin := engine.Group("/api/admin/v1/devices").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
admin.GET("", handler.List)
|
||||
admin.GET("/owners", middleware.RequireRoleKey("admin"), handler.Owners)
|
||||
admin.PATCH("/:deviceId/owner", middleware.RequireRoleKey("admin"), handler.SetOwner)
|
||||
admin.POST("/:deviceId/disable", middleware.RequireRoleKey("admin"), handler.Disable)
|
||||
admin.POST("/:deviceId/identity-reset", middleware.RequireRoleKey("admin"), handler.ResetIdentity)
|
||||
admin.POST("/:deviceId/token/revoke", middleware.RequireRoleKey("admin"), handler.RevokeToken)
|
||||
|
||||
@@ -56,6 +56,9 @@ func MigratedModels() []any {
|
||||
&models.PDDAccount{},
|
||||
&models.PurchaseTask{},
|
||||
&models.PurchaseTaskAttempt{},
|
||||
&models.PurchaseOrderWriteback{},
|
||||
&models.PurchaseOrderWritebackLease{},
|
||||
&models.PurchaseOrderWritebackCommand{},
|
||||
&models.PurchaseSpecMatchWorkItem{},
|
||||
&models.CollectionRule{},
|
||||
&models.PurchaseRule{},
|
||||
@@ -88,7 +91,12 @@ func Migrate(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
// #269: seed immutable structure判据 and the initial keyword safety net.
|
||||
seed := []struct{ kind, keyword string }{{"char", "-"}, {"char", "#"}, {"keyword", "CVC短袖220斤"}, {"keyword", "180克純棉圓領短袖"}, {"keyword", "拼色純棉短袖"}, {"keyword", "300斤牛奶絲圓領"}, {"keyword", "印花大學T"}, {"keyword", "印花帽T"}}
|
||||
//
|
||||
// `[必须]` 结构过滤自 #286 起只有**一条**,keyword 存的是必需字符集合
|
||||
// `-#`,意思是“同时包含 - 和 #”。这里不能再播种旧的两条:本函数
|
||||
// 每次启动都会 FirstOrCreate,播了就会把迁移 1789500000000 合并掉的
|
||||
// `-` 和 `#` 重新建回来,过滤静默退回或关系。
|
||||
seed := []struct{ kind, keyword string }{{"char", "-#"}, {"keyword", "CVC短袖220斤"}, {"keyword", "180克純棉圓領短袖"}, {"keyword", "拼色純棉短袖"}, {"keyword", "300斤牛奶絲圓領"}, {"keyword", "印花大學T"}, {"keyword", "印花帽T"}}
|
||||
for _, item := range seed {
|
||||
norm := sybshop.Normalize(item.keyword)
|
||||
row := models.SYBProductFilter{Kind: item.kind, Keyword: item.keyword, NormalizedKeyword: norm, Enabled: true}
|
||||
@@ -96,7 +104,41 @@ func Migrate(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return ensureMySQLDirectSelectConstraint(db)
|
||||
if err := ensureMySQLDirectSelectConstraint(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return EnsureCollectionTaskCancelledConstraint(db)
|
||||
}
|
||||
|
||||
// ensureMySQLCollectionTaskCancelledConstraint 放开 collection_task.status 的
|
||||
// CHECK 约束以接受 'cancelled'(#297)。GORM 的 AutoMigrate 在 MySQL 上不会
|
||||
// 改写已存在的 CHECK 约束(同 ensureMySQLDirectSelectConstraint 的已知限制),
|
||||
// 所以新增取消状态必须像 direct_select 那次一样手动 DROP/ADD,否则老库的约束
|
||||
// 仍然只认旧的五个状态,取消写入会被数据库直接拒绝。
|
||||
// EnsureCollectionTaskCancelledConstraint is exported so the version-local
|
||||
// migration can call it: that file is the only path that reaches an existing
|
||||
// database (see 1789700000000_collection_task_cancelled.go).
|
||||
func EnsureCollectionTaskCancelledConstraint(db *gorm.DB) error {
|
||||
if db.Dialector.Name() != "mysql" {
|
||||
return nil
|
||||
}
|
||||
const name = "ck_collection_task_status"
|
||||
var constraints []struct {
|
||||
CheckClause string `gorm:"column:check_clause"`
|
||||
}
|
||||
if err := db.Raw(mysqlCheckConstraintQuery, "collection_task", name).Scan(&constraints).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(constraints) > 0 && strings.Contains(strings.ToLower(constraints[0].CheckClause), "cancelled") {
|
||||
return nil
|
||||
}
|
||||
if len(constraints) > 0 {
|
||||
if err := db.Exec("ALTER TABLE collection_task DROP CHECK " + name).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Exec("ALTER TABLE collection_task ADD CONSTRAINT " + name +
|
||||
" CHECK (status IN ('pending','running','completed','completed_partial','failed','cancelled'))").Error
|
||||
}
|
||||
|
||||
func ensureMySQLDirectSelectConstraint(db *gorm.DB) error {
|
||||
|
||||
@@ -138,12 +138,17 @@ type PurchaseTask struct {
|
||||
StatusVersion uint64 `json:"statusVersion" gorm:"not null;default:1"`
|
||||
StatusChangedAt time.Time `json:"statusChangedAt" gorm:"not null"`
|
||||
|
||||
PDDOrderNo *string `json:"pddOrderNo" gorm:"size:100;index"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt"`
|
||||
IrreversibleAt *time.Time `json:"irreversibleAt"`
|
||||
PaymentReviewStatus string `json:"paymentReviewStatus" gorm:"size:16;not null;default:pending;check:ck_purchase_task_payment_review,payment_review_status IN ('pending','paid','unpaid')"`
|
||||
PaymentReviewedAt *time.Time `json:"paymentReviewedAt"`
|
||||
PaymentReviewedBy *uint64 `json:"paymentReviewedBy"`
|
||||
PDDOrderNo *string `json:"pddOrderNo" gorm:"size:100;index"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt"`
|
||||
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty" gorm:"check:ck_purchase_task_order_amount,pdd_order_amount_cent IS NULL OR pdd_order_amount_cent >= 0"`
|
||||
IrreversibleAt *time.Time `json:"irreversibleAt"`
|
||||
// PaymentPageObservedAt 仅在 order_result_unknown 时可能有值:采样期间见过
|
||||
// 待付款/支付相关文案,说明大概率已经在 PDD 建了订单,只是没能读全证据。
|
||||
// 用于给积压的 order_result_unknown 记录分优先级,不改变判定结果本身(#302)。
|
||||
PaymentPageObservedAt *time.Time `json:"paymentPageObservedAt"`
|
||||
PaymentReviewStatus string `json:"paymentReviewStatus" gorm:"size:16;not null;default:pending;check:ck_purchase_task_payment_review,payment_review_status IN ('pending','paid','unpaid')"`
|
||||
PaymentReviewedAt *time.Time `json:"paymentReviewedAt"`
|
||||
PaymentReviewedBy *uint64 `json:"paymentReviewedBy"`
|
||||
|
||||
TrackingNo *string `json:"trackingNo" gorm:"size:120"`
|
||||
TrackingCollectedAt *time.Time `json:"trackingCollectedAt"`
|
||||
@@ -202,7 +207,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
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// PurchaseOrderWriteback is independent of the legacy logistics writeback fields.
|
||||
type PurchaseOrderWriteback struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement"`
|
||||
PurchaseTaskID uint64 `gorm:"not null;uniqueIndex"`
|
||||
StockID int64 `gorm:"not null;index:idx_order_writeback_target,priority:1"`
|
||||
DetailID int64 `gorm:"not null;index:idx_order_writeback_target,priority:2"`
|
||||
OrderNo string `json:"-" gorm:"size:100;not null"`
|
||||
Status string `gorm:"size:24;not null;index"`
|
||||
AttemptCount int `gorm:"not null;default:0"`
|
||||
WriteStarted bool `gorm:"not null;default:false"`
|
||||
LeaseOwner string `gorm:"size:36"`
|
||||
LeaseExpiresAt *time.Time
|
||||
ErrorCode string `gorm:"size:80"`
|
||||
ErrorMessage string `gorm:"size:300"`
|
||||
CompletedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (PurchaseOrderWriteback) TableName() string { return "purchase_order_writeback" }
|
||||
|
||||
// A singleton lease serializes all SYB order-number writers, including different
|
||||
// tasks that resolve to the same remote stock/detail identity.
|
||||
type PurchaseOrderWritebackLease struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
Owner string `gorm:"size:36"`
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
func (PurchaseOrderWritebackLease) TableName() string { return "purchase_order_writeback_lease" }
|
||||
|
||||
type PurchaseOrderWritebackCommand struct {
|
||||
RequestID string `gorm:"primaryKey;size:36"`
|
||||
InputHash string `gorm:"size:64;not null"`
|
||||
ResultJSON string `json:"-" gorm:"type:text;not null"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (PurchaseOrderWritebackCommand) TableName() string { return "purchase_order_writeback_command" }
|
||||
@@ -17,6 +17,9 @@ const (
|
||||
TaskStatusCompleted = "completed"
|
||||
TaskStatusCompletedPartial = "completed_partial"
|
||||
TaskStatusFailed = "failed"
|
||||
// TaskStatusCancelled 与 PurchaseTaskStatusCancelled 的既有约定对齐(#297):
|
||||
// 只有 pending 任务允许取消,取消后是终态,不再被任何设备领取。
|
||||
TaskStatusCancelled = "cancelled"
|
||||
|
||||
CollectionTaskSourceAdmin = "admin"
|
||||
CollectionTaskSourceAgentCurrentPage = "agent_current_page"
|
||||
@@ -35,6 +38,7 @@ type AgentDevice struct {
|
||||
AndroidVersion string `json:"androidVersion" gorm:"size:32;not null"`
|
||||
AgentVersion string `json:"agentVersion" gorm:"size:32;not null"`
|
||||
PDDVersion string `json:"pddVersion" gorm:"size:32;not null"`
|
||||
OwnerUserID *uint64 `json:"ownerUserId" gorm:"column:owner_user_id;index"`
|
||||
CapabilitiesJSON string `json:"-" gorm:"size:4096;not null;default:'[]'"`
|
||||
Status string `json:"status" gorm:"size:16;not null;index;check:ck_agent_device_status,status IN ('online','offline','disabled')"`
|
||||
TokenDigest string `json:"-" gorm:"size:64;not null;uniqueIndex:ux_agent_device_token_digest"`
|
||||
@@ -183,7 +187,7 @@ type CollectionTask struct {
|
||||
DeviceID *uint64 `json:"deviceId" gorm:"index;uniqueIndex:ux_collection_task_running_device,priority:1"`
|
||||
Device *AgentDevice `json:"-"`
|
||||
Source string `json:"source" gorm:"size:32;not null;default:admin;index;check:ck_collection_task_source,source IN ('admin','agent_current_page','image_search')"`
|
||||
Status string `json:"status" gorm:"size:24;not null;index;check:ck_collection_task_status,status IN ('pending','running','completed','completed_partial','failed')"`
|
||||
Status string `json:"status" gorm:"size:24;not null;index;check:ck_collection_task_status,status IN ('pending','running','completed','completed_partial','failed','cancelled')"`
|
||||
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_active_product,priority:2;check:ck_collection_task_active_slot,(status IN ('pending','running') AND active_slot = 1) OR (status NOT IN ('pending','running') AND active_slot IS NULL)"`
|
||||
DeviceRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_running_device,priority:2;check:ck_collection_task_device_run_slot,(status = 'running' AND device_id IS NOT NULL AND device_run_slot = 1) OR (status <> 'running' AND device_run_slot IS NULL)"`
|
||||
URLSnapshot string `json:"urlSnapshot" gorm:"type:text;not null"`
|
||||
@@ -277,7 +281,7 @@ func (task *CollectionTask) syncGuardSlots() error {
|
||||
}
|
||||
task.ActiveSlot = &one
|
||||
task.DeviceRunSlot = &one
|
||||
case TaskStatusCompleted, TaskStatusCompletedPartial, TaskStatusFailed:
|
||||
case TaskStatusCompleted, TaskStatusCompletedPartial, TaskStatusFailed, TaskStatusCancelled:
|
||||
task.ActiveSlot = nil
|
||||
task.DeviceRunSlot = nil
|
||||
default:
|
||||
@@ -420,6 +424,15 @@ type ShopeeProduct struct {
|
||||
// PDD product.
|
||||
PDDProductID *uint64 `json:"pddProductId" gorm:"index"`
|
||||
ImageSearchLinked bool `json:"imageSearchLinked" gorm:"not null;default:false;index"`
|
||||
// SpecSyncPDDProductID records which PDD product the full Shopee spec list
|
||||
// was synced and matched against (#290).
|
||||
//
|
||||
// `[必须]` 存 PDD 商品 id 而不是布尔值:映射是对着某个 PDD 商品的规格值建的,
|
||||
// 一旦重新关联到别的 PDD 商品,旧映射全部失效。跟踪具体是哪个商品,则跳过
|
||||
// 判据变成 SpecSyncPDDProductID == PDDProductID,重新关联时标记自动失效,
|
||||
// 不依赖“记得去清”。
|
||||
SpecSyncPDDProductID *uint64 `json:"specSyncPddProductId,omitempty" gorm:"index"`
|
||||
SpecSyncAt *time.Time `json:"specSyncAt,omitempty"`
|
||||
// ImageURL holds the SYB-provided reference image URL. It is written by the
|
||||
// #41 import and may be overridden manually; the product domain never joins
|
||||
// syb_products at read time.
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -30,9 +31,16 @@ func (handler Handler) List(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.List(c.Request.Context(), ListRequest{
|
||||
request := ListRequest{
|
||||
Page: page, PageSize: pageSize, GoodsID: c.Query("goodsId"), Keyword: c.Query("keyword"), Status: strings.TrimSpace(c.Query("status")),
|
||||
})
|
||||
}
|
||||
claims := jwt.ExtractClaims(c)
|
||||
if role, _ := claims["rolekey"].(string); role != "admin" {
|
||||
id, _ := claims["identity"].(float64)
|
||||
owner := uint64(id)
|
||||
request.CollectionOwnerUserID = &owner
|
||||
}
|
||||
response, err := service.List(c.Request.Context(), request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
|
||||
@@ -64,10 +64,11 @@ type UpdateRequest struct {
|
||||
}
|
||||
|
||||
type ListRequest struct {
|
||||
Page, PageSize int
|
||||
Keyword string
|
||||
GoodsID string
|
||||
Status string
|
||||
Page, PageSize int
|
||||
Keyword string
|
||||
GoodsID string
|
||||
Status string
|
||||
CollectionOwnerUserID *uint64
|
||||
}
|
||||
|
||||
type ProductView struct {
|
||||
@@ -266,6 +267,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
}
|
||||
query = query.Where("status = ?", request.Status)
|
||||
}
|
||||
if request.CollectionOwnerUserID != nil {
|
||||
query = query.Where("EXISTS (SELECT 1 FROM collection_task ct JOIN agent_device ad ON ad.id = ct.device_id WHERE ct.pdd_product_id = pdd_product.id AND ct.source = ? AND ct.status IN ? AND ad.owner_user_id = ?)", models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial}, *request.CollectionOwnerUserID)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return ListResponse{}, internalError(err)
|
||||
|
||||
@@ -205,6 +205,44 @@ func TestListMarksProductsUnavailableForCollection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFiltersManualAssociationProductsByOwnedCollectionDevice(t *testing.T) {
|
||||
db := openProductDatabase(t)
|
||||
service := NewService(db)
|
||||
rule := models.CollectionRule{Name: "owned-rule", ContentJSON: `{}`}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
products := []models.PDDProduct{{GoodsID: "910001", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=910001", Status: "active"}, {GoodsID: "910002", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=910002", Status: "active"}, {GoodsID: "910003", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=910003", Status: "active"}}
|
||||
if err := db.Create(&products).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned := models.AgentDevice{InstallID: "owned-manual-list", Name: "owned", Manufacturer: "test", Model: "test", AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", Status: models.DeviceStatusOffline, TokenDigest: "owned-digest", TokenIssuedAt: time.Now(), OwnerUserID: ptrUint64(41)}
|
||||
other := models.AgentDevice{InstallID: "other-manual-list", Name: "other", Manufacturer: "test", Model: "test", AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", Status: models.DeviceStatusOffline, TokenDigest: "other-digest", TokenIssuedAt: time.Now(), OwnerUserID: ptrUint64(42)}
|
||||
if err := db.Create(&owned).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&other).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, task := range []models.CollectionTask{{PDDProductID: &products[0].ID, DeviceID: &owned.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted}, {PDDProductID: &products[1].ID, DeviceID: &other.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted}, {PDDProductID: &products[2].ID, DeviceID: &owned.ID, Source: models.CollectionTaskSourceAdmin, Status: models.TaskStatusCompleted}} {
|
||||
task.RuleID = rule.ID
|
||||
task.URLSnapshot = products[0].URL
|
||||
task.RuleSnapshot = rule.ContentJSON
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := service.List(context.Background(), ListRequest{Page: 1, PageSize: 20, CollectionOwnerUserID: ptrUint64(41)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Items) != 1 || got.Items[0].GoodsID != products[0].GoodsID {
|
||||
t.Fatalf("unexpected owned products: %+v", got.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrUint64(value uint64) *uint64 { return &value }
|
||||
|
||||
func TestListDisablesCollectionWhenNoRuleExists(t *testing.T) {
|
||||
db := openProductDatabase(t)
|
||||
product := models.PDDProduct{GoodsID: "444444", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=444444", Status: "active"}
|
||||
|
||||
@@ -25,57 +25,60 @@ type AdminListRequest struct {
|
||||
}
|
||||
|
||||
type AdminTaskItem struct {
|
||||
ID uint64 `json:"id"`
|
||||
TaskType string `json:"taskType"`
|
||||
ExecutionMode string `json:"executionMode"`
|
||||
Status string `json:"status"`
|
||||
SYBProductID *uint64 `json:"sybProductId,omitempty"`
|
||||
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
|
||||
PDDProductID uint64 `json:"pddProductId"`
|
||||
DeviceID *uint64 `json:"deviceId,omitempty"`
|
||||
DeviceName string `json:"deviceName,omitempty"`
|
||||
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
|
||||
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
|
||||
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
|
||||
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"`
|
||||
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"`
|
||||
PDDTitleSnapshot string `json:"pddTitleSnapshot"`
|
||||
TargetColorSnapshot string `json:"targetColorSnapshot"`
|
||||
TargetSizeSnapshot string `json:"targetSizeSnapshot"`
|
||||
MappedColorSnapshot string `json:"mappedColorSnapshot"`
|
||||
MappedSizeSnapshot string `json:"mappedSizeSnapshot"`
|
||||
SpecSource string `json:"specSource"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent"`
|
||||
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
|
||||
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
|
||||
Currency string `json:"currency"`
|
||||
PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot"`
|
||||
AddressSuffix string `json:"addressSuffix"`
|
||||
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
|
||||
IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"`
|
||||
PaymentReviewStatus string `json:"paymentReviewStatus"`
|
||||
PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"`
|
||||
TrackingNo *string `json:"trackingNo,omitempty"`
|
||||
TrackingCollectedAt *time.Time `json:"trackingCollectedAt,omitempty"`
|
||||
LogisticsStatus string `json:"logisticsStatus"`
|
||||
WritebackStatus string `json:"writebackStatus"`
|
||||
WritebackAt *time.Time `json:"writebackAt,omitempty"`
|
||||
RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt,omitempty"`
|
||||
RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt,omitempty"`
|
||||
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
|
||||
CancelReason *string `json:"cancelReason,omitempty"`
|
||||
ErrorCode *string `json:"errorCode,omitempty"`
|
||||
ErrorMessage *string `json:"errorMessage,omitempty"`
|
||||
Retryable bool `json:"retryable"`
|
||||
RetryDisabledCode string `json:"retryDisabledCode,omitempty"`
|
||||
RetryDisabledReason string `json:"retryDisabledReason,omitempty"`
|
||||
StatusVersion uint64 `json:"statusVersion"`
|
||||
StatusChangedAt time.Time `json:"statusChangedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Matching MatchingView `json:"matching"`
|
||||
OrderWriteback OrderWritebackView `json:"orderWriteback"`
|
||||
ID uint64 `json:"id"`
|
||||
TaskType string `json:"taskType"`
|
||||
ExecutionMode string `json:"executionMode"`
|
||||
Status string `json:"status"`
|
||||
SYBProductID *uint64 `json:"sybProductId,omitempty"`
|
||||
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
|
||||
PDDProductID uint64 `json:"pddProductId"`
|
||||
DeviceID *uint64 `json:"deviceId,omitempty"`
|
||||
DeviceName string `json:"deviceName,omitempty"`
|
||||
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
|
||||
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
|
||||
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
|
||||
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"`
|
||||
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"`
|
||||
PDDTitleSnapshot string `json:"pddTitleSnapshot"`
|
||||
TargetColorSnapshot string `json:"targetColorSnapshot"`
|
||||
TargetSizeSnapshot string `json:"targetSizeSnapshot"`
|
||||
MappedColorSnapshot string `json:"mappedColorSnapshot"`
|
||||
MappedSizeSnapshot string `json:"mappedSizeSnapshot"`
|
||||
SpecSource string `json:"specSource"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent"`
|
||||
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
|
||||
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
|
||||
Currency string `json:"currency"`
|
||||
PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot"`
|
||||
AddressSuffix string `json:"addressSuffix"`
|
||||
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
|
||||
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
|
||||
IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"`
|
||||
PaymentReviewStatus string `json:"paymentReviewStatus"`
|
||||
PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"`
|
||||
TrackingNo *string `json:"trackingNo,omitempty"`
|
||||
TrackingCollectedAt *time.Time `json:"trackingCollectedAt,omitempty"`
|
||||
LogisticsStatus string `json:"logisticsStatus"`
|
||||
WritebackStatus string `json:"writebackStatus"`
|
||||
WritebackAt *time.Time `json:"writebackAt,omitempty"`
|
||||
RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt,omitempty"`
|
||||
RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt,omitempty"`
|
||||
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
|
||||
CancelReason *string `json:"cancelReason,omitempty"`
|
||||
ErrorCode *string `json:"errorCode,omitempty"`
|
||||
ErrorMessage *string `json:"errorMessage,omitempty"`
|
||||
Retryable bool `json:"retryable"`
|
||||
RetryDisabledCode string `json:"retryDisabledCode,omitempty"`
|
||||
RetryDisabledReason string `json:"retryDisabledReason,omitempty"`
|
||||
StatusVersion uint64 `json:"statusVersion"`
|
||||
StatusChangedAt time.Time `json:"statusChangedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Matching MatchingView `json:"matching"`
|
||||
ImageSearchLinked bool `json:"imageSearchLinked,omitempty"`
|
||||
}
|
||||
|
||||
type AdminAttemptItem struct {
|
||||
@@ -106,6 +109,9 @@ type AdminDetailResponse struct {
|
||||
Matching MatchingView `json:"matching"`
|
||||
}
|
||||
|
||||
// Query-only value: never a PurchaseTask status or a logistics writeback state.
|
||||
const adminStatusSYBWritebackSucceeded = "syb_writeback_succeeded"
|
||||
|
||||
func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminListResponse, error) {
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
@@ -116,7 +122,7 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
if req.PageSize > 100 {
|
||||
req.PageSize = 100
|
||||
}
|
||||
if req.Status != "" && !validPurchaseStatus(req.Status) {
|
||||
if req.Status != "" && req.Status != adminStatusSYBWritebackSucceeded && !validPurchaseStatus(req.Status) {
|
||||
return AdminListResponse{}, fail(CodeInvalidRequest, "status 无效")
|
||||
}
|
||||
if req.ExecutionMode != "" && req.ExecutionMode != models.PurchaseExecutionModeRehearsal && req.ExecutionMode != models.PurchaseExecutionModeLive {
|
||||
@@ -129,7 +135,9 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
if req.TaskID > 0 {
|
||||
query = query.Where("id = ?", req.TaskID)
|
||||
}
|
||||
if req.Status != "" {
|
||||
if req.Status == adminStatusSYBWritebackSucceeded {
|
||||
query = query.Where("id IN (?)", s.DB.WithContext(ctx).Model(&models.PurchaseOrderWriteback{}).Select("purchase_task_id").Where("status = ?", "succeeded"))
|
||||
} else if req.Status != "" {
|
||||
query = query.Where("status = ?", req.Status)
|
||||
}
|
||||
if req.ExecutionMode != "" {
|
||||
@@ -163,15 +171,53 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
imageSearchLinked, err := s.loadImageSearchLinked(ctx, tasks)
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
items := make([]AdminTaskItem, 0, len(tasks))
|
||||
writebacks, err := s.OrderWritebackViews(ctx, tasks)
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
for _, task := range tasks {
|
||||
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
|
||||
item.OrderWriteback = writebacks[task.ID]
|
||||
item.Matching = matching[task.ID]
|
||||
if task.ShopeeProductID != nil {
|
||||
item.ImageSearchLinked = imageSearchLinked[*task.ShopeeProductID]
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return AdminListResponse{Items: items, Total: total, Page: req.Page, PageSize: req.PageSize}, nil
|
||||
}
|
||||
|
||||
// loadImageSearchLinked bulk-loads the read-only "linked via image search, not
|
||||
// manually verified" flag for purchase-task list/detail views (#279 item 5).
|
||||
// It is display-only and must never influence eligibility or matching.
|
||||
func (s *Service) loadImageSearchLinked(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]bool, error) {
|
||||
result := make(map[uint64]bool)
|
||||
ids := make([]uint64, 0, len(tasks))
|
||||
seen := make(map[uint64]bool)
|
||||
for _, task := range tasks {
|
||||
if task.ShopeeProductID != nil && !seen[*task.ShopeeProductID] {
|
||||
ids = append(ids, *task.ShopeeProductID)
|
||||
seen[*task.ShopeeProductID] = true
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var shopeeProducts []models.ShopeeProduct
|
||||
if err := s.DB.WithContext(ctx).Select("id, image_search_linked").Where("id IN ?", ids).Find(&shopeeProducts).Error; err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
for _, shopee := range shopeeProducts {
|
||||
result[shopee.ID] = shopee.ImageSearchLinked
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailResponse, error) {
|
||||
var task models.PurchaseTask
|
||||
if err := s.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
|
||||
@@ -202,7 +248,19 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe
|
||||
return AdminDetailResponse{}, matchErr
|
||||
}
|
||||
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
|
||||
writebacks, wbErr := s.OrderWritebackViews(ctx, []models.PurchaseTask{task})
|
||||
if wbErr != nil {
|
||||
return AdminDetailResponse{}, wbErr
|
||||
}
|
||||
item.OrderWriteback = writebacks[task.ID]
|
||||
item.Matching = matching
|
||||
imageSearchLinked, err := s.loadImageSearchLinked(ctx, []models.PurchaseTask{task})
|
||||
if err != nil {
|
||||
return AdminDetailResponse{}, err
|
||||
}
|
||||
if task.ShopeeProductID != nil {
|
||||
item.ImageSearchLinked = imageSearchLinked[*task.ShopeeProductID]
|
||||
}
|
||||
return AdminDetailResponse{Task: item, Attempts: items, Matching: matching}, nil
|
||||
}
|
||||
|
||||
@@ -241,7 +299,7 @@ func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string, retr
|
||||
SpecSource: task.SpecSource, Quantity: task.Quantity, ReferenceUnitPriceCent: task.ReferenceUnitPriceCent,
|
||||
MinUnitPriceCent: task.MinUnitPriceCent, MaxUnitPriceCent: task.MaxUnitPriceCent, Currency: task.Currency,
|
||||
PDDAccountRefSnapshot: task.PDDAccountRefSnapshot, AddressSuffix: task.AddressSuffix,
|
||||
PDDOrderNo: task.PDDOrderNo, OrderSubmittedAt: task.OrderSubmittedAt, IrreversibleAt: task.IrreversibleAt,
|
||||
PDDOrderNo: task.PDDOrderNo, OrderSubmittedAt: task.OrderSubmittedAt, PDDOrderAmountCent: task.PDDOrderAmountCent, IrreversibleAt: task.IrreversibleAt,
|
||||
PaymentReviewStatus: task.PaymentReviewStatus, PaymentReviewedAt: task.PaymentReviewedAt,
|
||||
TrackingNo: task.TrackingNo, TrackingCollectedAt: task.TrackingCollectedAt,
|
||||
LogisticsStatus: task.LogisticsStatus, WritebackStatus: task.WritebackStatus, WritebackAt: task.WritebackAt,
|
||||
|
||||
@@ -105,6 +105,63 @@ func TestAdminQueryValidationAndNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminListSYBWritebackSucceededFilter(t *testing.T) {
|
||||
s, first := orderWritebackFixture(t)
|
||||
db := s.DB
|
||||
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("purchase_task_id = ?", first.ID).Update("status", "succeeded").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var successIDs []uint64
|
||||
successIDs = append(successIDs, first.ID)
|
||||
for _, state := range []string{"succeeded", "pending", "running", "failed", "unknown", "conflict", "none"} {
|
||||
task := first
|
||||
task.ID = 0
|
||||
task.Status = models.PurchaseTaskStatusCancelled
|
||||
task.PDDOrderNo = nil
|
||||
task.WritebackStatus = models.PurchaseWritebackStatusSucceeded // old logistics must not match
|
||||
task.CreateRequestID = uuid.NewString()
|
||||
task.UnknownResolveRequestID = nil
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != "none" {
|
||||
if err := db.Create(&models.PurchaseOrderWriteback{PurchaseTaskID: task.ID, StockID: 2, DetailID: 1, OrderNo: "FILTER-DEMO", Status: state}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if state == "succeeded" {
|
||||
successIDs = append(successIDs, task.ID)
|
||||
}
|
||||
}
|
||||
for page := 1; page <= 2; page++ {
|
||||
out, err := s.AdminList(context.Background(), AdminListRequest{Status: adminStatusSYBWritebackSucceeded, Page: page, PageSize: 1})
|
||||
if err != nil || out.Total != 2 || len(out.Items) != 1 || out.Items[0].ID != successIDs[2-page] {
|
||||
t.Fatalf("page %d: %+v, %v", page, out, err)
|
||||
}
|
||||
}
|
||||
for _, req := range []AdminListRequest{
|
||||
{Status: adminStatusSYBWritebackSucceeded, TaskID: first.ID},
|
||||
{Status: models.PurchaseTaskStatusOrderCreated},
|
||||
{Status: adminStatusSYBWritebackSucceeded, PDDOrderNo: *first.PDDOrderNo, ExecutionMode: "live", TaskType: "syb_order", SYBProductID: *first.SYBProductID},
|
||||
} {
|
||||
out, err := s.AdminList(context.Background(), req)
|
||||
if err != nil || out.Total != 1 || len(out.Items) != 1 || out.Items[0].ID != first.ID {
|
||||
t.Fatalf("combined filter: %+v %v", out, err)
|
||||
}
|
||||
}
|
||||
out, err := s.AdminList(context.Background(), AdminListRequest{})
|
||||
if err != nil || out.Total != 8 {
|
||||
t.Fatalf("clear filter: %+v %v", out, err)
|
||||
}
|
||||
out, err = s.AdminList(context.Background(), AdminListRequest{Status: adminStatusSYBWritebackSucceeded, PDDOrderNo: "not-found"})
|
||||
if err != nil || out.Total != 0 {
|
||||
t.Fatalf("empty filter: %+v %v", out, err)
|
||||
}
|
||||
if validPurchaseStatus(adminStatusSYBWritebackSucceeded) {
|
||||
t.Fatal("filter became task state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminQueryHandlersRequireOperatorRole(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, path := range []string{"/api/admin/v1/purchase-tasks", "/api/admin/v1/purchase-tasks/1"} {
|
||||
@@ -125,7 +182,7 @@ func TestAdminQueryHandlersRequireOperatorRole(t *testing.T) {
|
||||
|
||||
func TestAdminBatchHandlersRequireOperatorRole(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, handler := range []func(*gin.Context){(Handler{}).AdminBatchPreview, (Handler{}).AdminBatchCreate, (Handler{}).AdminBatchRetry} {
|
||||
for _, handler := range []func(*gin.Context){(Handler{}).AdminBatchPreview, (Handler{}).AdminBatchCreate, (Handler{}).AdminBatchRetry, (Handler{}).AdminOrderWriteback} {
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
context.Request = httptest.NewRequest(http.MethodPost, "/api/admin/v1/purchase-tasks/batch", strings.NewReader(`{}`))
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ArchiveSpecMatchResult counts what one archive-wide matching pass did.
|
||||
//
|
||||
// NoMatch 与 Unavailable 分开计数:前者是 AI 明确说没有可靠对应(PDD 确实没有该
|
||||
// 颜色,或候选有歧义),后者是服务不可用。两者的处置完全不同,此前都记成 failed,
|
||||
// 排查时分不开(#295)。
|
||||
type ArchiveSpecMatchResult struct {
|
||||
Confirmed int
|
||||
Skipped int
|
||||
NoMatch int
|
||||
Unavailable int
|
||||
// Aborted 是本端 context 取消或超时导致的中断,与 Provider 故障无关(#300)。
|
||||
Aborted int
|
||||
}
|
||||
|
||||
// Failed is every value that ended without a mapping, whatever the reason.
|
||||
func (r ArchiveSpecMatchResult) Failed() int { return r.NoMatch + r.Unavailable + r.Aborted }
|
||||
|
||||
// MatchArchiveSpecs matches every still-unmapped value in a Shopee product's
|
||||
// archive against its linked PDD product (#293).
|
||||
//
|
||||
// 存在的理由:BatchSpecMatch 遍历的是 SYB 明细,只匹配明细需要的值。#290 把虾皮
|
||||
// 的完整颜色尺码同步进档案后,那些还没有订单的值一个都不会被尝试——等订单真来了
|
||||
// 仍然要人工点一次匹配,正是 #290 想消除的动作。
|
||||
//
|
||||
// `[必须]` 这里只写规格映射,不创建任何采购。匹配只把明细推到“采购就绪”,创建
|
||||
// 采购仍由人点击——那是整条链上唯一的人工检查点。
|
||||
func (s *Service) MatchArchiveSpecs(ctx context.Context, shopeeProductID uint64) (ArchiveSpecMatchResult, error) {
|
||||
var result ArchiveSpecMatchResult
|
||||
var shopee models.ShopeeProduct
|
||||
if err := s.DB.WithContext(ctx).First(&shopee, shopeeProductID).Error; err != nil {
|
||||
return result, internal(err)
|
||||
}
|
||||
if shopee.PDDProductID == nil {
|
||||
return result, nil
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := s.DB.WithContext(ctx).First(&pdd, *shopee.PDDProductID).Error; err != nil {
|
||||
return result, internal(err)
|
||||
}
|
||||
if pdd.Status != "active" {
|
||||
return result, nil
|
||||
}
|
||||
candidates, usable := archiveCandidates(pdd.SpecsJSON, "", "")
|
||||
if !usable {
|
||||
return result, nil
|
||||
}
|
||||
combinations, err := s.archiveSKUCombinations(ctx, pdd.ID)
|
||||
if err != nil {
|
||||
return result, internal(err)
|
||||
}
|
||||
|
||||
specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON)
|
||||
if err != nil {
|
||||
return result, nil
|
||||
}
|
||||
for _, dimension := range specs {
|
||||
if dimension.Role != shopeeproduct.RoleColor && dimension.Role != shopeeproduct.RoleSize {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
if value.Mapping != nil && value.Mapping.Status == shopeeproduct.MappingStatusConfirmed {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
switch s.matchOneArchiveValue(ctx, shopeeProductID, dimension, value.Name, candidates, combinations) {
|
||||
case specMatchAborted:
|
||||
// ctx 已死,后面的值一个也做不成,早停以免刷出一串同样的失败。
|
||||
result.Aborted++
|
||||
return result, nil
|
||||
case specMatchUnavailable:
|
||||
result.Unavailable++
|
||||
case specMatchNoMatch:
|
||||
result.NoMatch++
|
||||
default:
|
||||
result.Confirmed++
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// matchOneArchiveValue resolves a single archive value and persists it when the
|
||||
// answer survives every check. An empty return means a mapping was written.
|
||||
func (s *Service) matchOneArchiveValue(
|
||||
ctx context.Context,
|
||||
shopeeProductID uint64,
|
||||
dimension shopeeproduct.SpecDimension,
|
||||
valueName string,
|
||||
candidates specCandidates,
|
||||
combinations []pddSKUCombination,
|
||||
) specMatchFailure {
|
||||
isColor := dimension.Role == shopeeproduct.RoleColor
|
||||
request := aimatching.MatchRequest{Colors: candidates.Colors, Sizes: candidates.Sizes}
|
||||
if isColor {
|
||||
request.TargetColor = valueName
|
||||
} else {
|
||||
request.TargetSize = valueName
|
||||
}
|
||||
matched, ok := aimatching.DeterministicMatch(request)
|
||||
if !ok {
|
||||
resolved, failure, err := s.resolveSpecMatch(ctx, request)
|
||||
if err != nil {
|
||||
return failure
|
||||
}
|
||||
// #200 的口径:AI 只要给出带理由的结果就可保存,是否放行交给下面的候选与
|
||||
// 可售组合校验;置信度只作审计信息。
|
||||
if resolved.Source != aimatching.SourceAI || strings.TrimSpace(resolved.Decision.Reason) == "" {
|
||||
return specMatchNoMatch
|
||||
}
|
||||
matched = resolved
|
||||
}
|
||||
|
||||
pddValue := matched.MappedSize
|
||||
allowed := candidates.Sizes
|
||||
if isColor {
|
||||
pddValue, allowed = matched.MappedColor, candidates.Colors
|
||||
}
|
||||
pddValue = strings.TrimSpace(pddValue)
|
||||
if pddValue == "" || !containsValue(allowed, pddValue) {
|
||||
return specMatchNoMatch
|
||||
}
|
||||
// `[必须]` 逐值匹配没有另一半规格,无法校验完整的可售组合。至少要求这个值真的
|
||||
// 出现在某个在售 SKU 里,否则会把一个“看起来合理但根本买不到”的值写成确认映射。
|
||||
// 完整组合仍由采购预检把关。
|
||||
if !archiveValueIsSellable(combinations, isColor, pddValue) {
|
||||
return specMatchNoMatch
|
||||
}
|
||||
|
||||
item := shopeeproduct.ResolvedMappingItem{
|
||||
Dimension: dimension.Name, ValueName: valueName, PDDValue: pddValue,
|
||||
Source: matched.Source, Status: shopeeproduct.MappingStatusConfirmed,
|
||||
Confidence: matched.Decision.Confidence, Reason: strings.TrimSpace(matched.Decision.Reason),
|
||||
}
|
||||
if _, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopeeProductID, uuid.NewString(), []shopeeproduct.ResolvedMappingItem{item}); err != nil {
|
||||
return specMatchUnavailable
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// archiveValueIsSellable reports whether the PDD value appears in at least one
|
||||
// available SKU combination. No combinations means no evidence either way, so
|
||||
// the value passes — hand-entered or imported PDD archives have no SKU rows.
|
||||
func archiveValueIsSellable(combinations []pddSKUCombination, isColor bool, pddValue string) bool {
|
||||
if len(combinations) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, combination := range combinations {
|
||||
if isColor && combination.Color == pddValue {
|
||||
return true
|
||||
}
|
||||
if !isColor && combination.Size == pddValue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) archiveSKUCombinations(ctx context.Context, pddProductID uint64) ([]pddSKUCombination, error) {
|
||||
dataset := batchPreviewDataset{skuCombinationsByPDD: make(map[uint64][]pddSKUCombination)}
|
||||
if err := s.loadLatestSKUCombinations(ctx, []uint64{pddProductID}, &dataset); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dataset.skuCombinationsByPDD[pddProductID], nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package purchase
|
||||
|
||||
import "testing"
|
||||
|
||||
// `[必须]` 逐值匹配没有另一半规格,无法校验完整的可售组合,所以至少要求这个值真的
|
||||
// 出现在某个在售 SKU 里。否则会把“看起来合理但根本买不到”的值写成确认映射(#293)。
|
||||
func TestArchiveValueMustAppearInASellableSKU(t *testing.T) {
|
||||
combinations := []pddSKUCombination{
|
||||
{Color: "紫色-冰块猫", Size: "M 建议86-95斤"},
|
||||
{Color: "黑色-冰块猫", Size: "L 建议96-105斤"},
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
isColor bool
|
||||
pddValue string
|
||||
want bool
|
||||
}{
|
||||
{"在售颜色", true, "黑色-冰块猫", true},
|
||||
{"不在售颜色", true, "奶黄-百花", false},
|
||||
{"在售尺码", false, "L 建议96-105斤", true},
|
||||
{"不在售尺码", false, "4XL 建议136-150斤", false},
|
||||
{"颜色值不能拿去顶尺码", false, "黑色-冰块猫", false},
|
||||
}
|
||||
for _, item := range cases {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
if got := archiveValueIsSellable(combinations, item.isColor, item.pddValue); got != item.want {
|
||||
t.Fatalf("got %v, want %v", got, item.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 人工录入或外部导入的 PDD 档案没有采集 SKU 证据。没有证据时不能一律拒绝,
|
||||
// 否则这些商品永远匹配不上——与 aiMatchQualificationForDataset 的既有口径一致。
|
||||
func TestArchiveValuePassesWhenThereIsNoSKUEvidence(t *testing.T) {
|
||||
if !archiveValueIsSellable(nil, true, "任意颜色") {
|
||||
t.Fatal("没有 SKU 证据时不应拒绝")
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ type BatchPreviewItem struct {
|
||||
PDDProductID *uint64 `json:"pddProductId,omitempty"`
|
||||
PDDGoodsID string `json:"pddGoodsId,omitempty"`
|
||||
PDDTitle string `json:"pddTitle,omitempty"`
|
||||
ImageSearchLinked bool `json:"imageSearchLinked,omitempty"`
|
||||
TargetColor string `json:"targetColor,omitempty"`
|
||||
TargetSize string `json:"targetSize,omitempty"`
|
||||
MappedColor string `json:"mappedColor,omitempty"`
|
||||
@@ -136,6 +137,11 @@ type batchPreviewDataset struct {
|
||||
activeCollectionByPDD map[uint64]uint64
|
||||
collectionRuleAvailable bool
|
||||
skuCombinationsByPDD map[uint64][]pddSKUCombination
|
||||
// collapsedKeysByShopee 记录该虾皮商品下哪些 target_color / target_size
|
||||
// 是不安全的键——多个不同的虾皮原始规格剥离 【...】 后塌缩到了同一个值。
|
||||
// 见 #289 与 sybimport.RawSpecHalves。
|
||||
collapsedColorByShopee map[uint64]map[string]bool
|
||||
collapsedSizeByShopee map[uint64]map[string]bool
|
||||
}
|
||||
|
||||
// loadBatchPreviewDataset keeps the read-only preview on bounded bulk queries.
|
||||
@@ -144,13 +150,15 @@ type batchPreviewDataset struct {
|
||||
// Creation still revalidates each item.
|
||||
func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (batchPreviewDataset, error) {
|
||||
dataset := batchPreviewDataset{
|
||||
sybByID: make(map[uint64]models.SYBProduct, len(ids)),
|
||||
shopeeByID: make(map[uint64]models.ShopeeProduct),
|
||||
pddByID: make(map[uint64]models.PDDProduct),
|
||||
latestTaskBySYB: make(map[uint64]models.PurchaseTask),
|
||||
latestCollectionByPDD: make(map[uint64]models.CollectionTask),
|
||||
activeCollectionByPDD: make(map[uint64]uint64),
|
||||
skuCombinationsByPDD: make(map[uint64][]pddSKUCombination),
|
||||
sybByID: make(map[uint64]models.SYBProduct, len(ids)),
|
||||
shopeeByID: make(map[uint64]models.ShopeeProduct),
|
||||
pddByID: make(map[uint64]models.PDDProduct),
|
||||
latestTaskBySYB: make(map[uint64]models.PurchaseTask),
|
||||
latestCollectionByPDD: make(map[uint64]models.CollectionTask),
|
||||
activeCollectionByPDD: make(map[uint64]uint64),
|
||||
skuCombinationsByPDD: make(map[uint64][]pddSKUCombination),
|
||||
collapsedColorByShopee: make(map[uint64]map[string]bool),
|
||||
collapsedSizeByShopee: make(map[uint64]map[string]bool),
|
||||
}
|
||||
var sybProducts []models.SYBProduct
|
||||
if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil {
|
||||
@@ -164,6 +172,9 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba
|
||||
}
|
||||
}
|
||||
if len(shopeeIDs) > 0 {
|
||||
if err := s.loadCollapsedSpecKeys(ctx, shopeeIDs, &dataset); err != nil {
|
||||
return dataset, err
|
||||
}
|
||||
var shopeeProducts []models.ShopeeProduct
|
||||
if err := s.DB.WithContext(ctx).Where("id IN ?", shopeeIDs).Find(&shopeeProducts).Error; err != nil {
|
||||
return dataset, err
|
||||
@@ -388,6 +399,7 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
|
||||
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee"
|
||||
return item
|
||||
}
|
||||
item.ImageSearchLinked = shopee.ImageSearchLinked
|
||||
if !aiConfirmedSpecsCurrent(syb, shopee) {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "AI 解析依据已变化,请等待重新解析或人工修正", "reparse"
|
||||
return item
|
||||
@@ -422,6 +434,14 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
|
||||
}
|
||||
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
|
||||
}
|
||||
// `[必须]` 塌缩的键不是这条明细独有的:同一个虾皮商品下,多个不同的虾皮原始
|
||||
// 规格剥离 【...】 后落到了同一个 target_color / target_size,于是共用同一份
|
||||
// 映射,Agent 会为不同规格点击同一个 PDD 值。此时必须明确失败,不能让它显示
|
||||
// 为采购就绪——静默买错比不能采购严重得多(#289)。
|
||||
if reason := collapsedSpecReason(dataset, syb); reason != "" {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeSpecKeyAmbiguous, reason, "open_mapping"
|
||||
return item
|
||||
}
|
||||
// Readiness must agree with the mapping shown in the product detail. A
|
||||
// deterministic suggestion is only a preview; it is not a persisted,
|
||||
// confirmed mapping and must not make the row appear purchase-ready.
|
||||
|
||||
@@ -73,7 +73,8 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
|
||||
matched = *qualification.Deterministic
|
||||
} else {
|
||||
var matchErr error
|
||||
matched, matchErr = s.matcher().Resolve(ctx, request)
|
||||
// 一次抽风不该让这条明细永久留空,重试一次(#295)。
|
||||
matched, _, matchErr = s.resolveSpecMatch(ctx, request)
|
||||
if matchErr != nil {
|
||||
item.Reason = batchSpecMatchErrorMessage(matchErr)
|
||||
response.FailedCount++
|
||||
|
||||
@@ -310,8 +310,11 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
|
||||
if matcher.calls != 0 {
|
||||
t.Fatalf("read-only preview called AI matcher %d times", matcher.calls)
|
||||
}
|
||||
if queries != 7 {
|
||||
t.Fatalf("batch preview used %d queries, want 7 bounded queries including collection eligibility and current purchase rule", queries)
|
||||
// #289 新增一次有界批量查询(按蕃皮商品拉全部明细用于塔缩检测),
|
||||
// 因此从 7 变为 8。这条断言守的是“不得出现 N+1”,不是具体数字;
|
||||
// 只有新增的查询确实有界时才允许上调。
|
||||
if queries != 8 {
|
||||
t.Fatalf("batch preview used %d queries, want 8 bounded queries including collection eligibility, current purchase rule and collapsed spec keys", queries)
|
||||
}
|
||||
if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible || response.EligibleCount != 2 {
|
||||
t.Fatalf("unresolved rows remain eligible for live probing but are not purchase-ready: %+v", response)
|
||||
@@ -334,6 +337,91 @@ func TestBatchPreviewAllowsIncompleteArchivedCandidatesForMandatoryLiveProbe(t *
|
||||
}
|
||||
}
|
||||
|
||||
// #279 item 5: purchase preview must surface (read-only) that a shopee<->PDD
|
||||
// link was established by image search and not yet human-verified, without
|
||||
// changing eligibility, reason codes or next actions in any way.
|
||||
func TestBatchPreviewMarksImageSearchLinkedRowsWithoutAffectingEligibility(t *testing.T) {
|
||||
db := testDB(t)
|
||||
fixture := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, fixture.pdd.ID)
|
||||
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", fixture.shopee.ID).Update("image_search_linked", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{
|
||||
SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Items) != 1 {
|
||||
t.Fatalf("unexpected items: %+v", response)
|
||||
}
|
||||
item := response.Items[0]
|
||||
if !item.ImageSearchLinked {
|
||||
t.Fatalf("expected imageSearchLinked=true for a link created by image search: %+v", item)
|
||||
}
|
||||
if !item.Eligible || item.ReasonCode != "" || item.Reason != "" || item.NextAction != "" {
|
||||
t.Fatalf("imageSearchLinked marker must be display-only and must not change eligibility: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreviewDoesNotMarkManuallyLinkedRows(t *testing.T) {
|
||||
db := testDB(t)
|
||||
fixture := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, fixture.pdd.ID)
|
||||
// image_search_linked defaults to false for manually established links.
|
||||
|
||||
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{
|
||||
SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Items) != 1 {
|
||||
t.Fatalf("unexpected items: %+v", response)
|
||||
}
|
||||
if response.Items[0].ImageSearchLinked {
|
||||
t.Fatalf("manual link must not be marked imageSearchLinked: %+v", response.Items[0])
|
||||
}
|
||||
if !response.Items[0].Eligible {
|
||||
t.Fatalf("manual link must remain eligible: %+v", response.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: the imageSearchLinked flag must not change any existing
|
||||
// eligibility outcome, including the well-covered ineligible paths.
|
||||
func TestBatchPreviewImageSearchLinkedDoesNotChangeIneligibleOutcome(t *testing.T) {
|
||||
db := testDB(t)
|
||||
fixture := seed(t, db, liveCaps(), true)
|
||||
failed := fixture.syb
|
||||
failed.ID = 0
|
||||
failed.OrderCode = "SYB-FAILED-IMG"
|
||||
failed.DetailID++
|
||||
failed.ParseStatus = models.SYBParseStatusFailed
|
||||
failed.TargetColor, failed.TargetSize = "", ""
|
||||
if err := db.Create(&failed).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", fixture.shopee.ID).Update("image_search_linked", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{
|
||||
SYBProductIDs: []uint64{failed.ID}, DeviceID: &fixture.device.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Items) != 1 {
|
||||
t.Fatalf("unexpected items: %+v", response)
|
||||
}
|
||||
item := response.Items[0]
|
||||
if item.Eligible || item.ReasonCode != "SYB_PARSE_FAILED" || item.NextAction != "reparse" {
|
||||
t.Fatalf("image search linkage must not alter the existing ineligible outcome: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func setCollectedPDDPrice(t *testing.T, db *gorm.DB, productID uint64) {
|
||||
t.Helper()
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", productID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user