Compare commits

...
9 changed files with 496 additions and 20 deletions
@@ -167,6 +167,8 @@ data class ParsedPddScreen(
val quantityInputCount: Int = 0,
/** #331: address entry, payment entry and quantity input share one bounded panel container. */
val requiredEvidenceSameContainer: Boolean = false,
/** Only populated by the final-confirmation opt-in; existing selectedSummary behavior is unchanged. */
val finalSelectionSummaries: List<String> = emptyList(),
) {
/** The three #331 required spec-panel items are all visible. */
val hasRequiredPanelEvidence: Boolean get() = hasAddressEntry && hasPaymentEntry && hasQuantityInput
@@ -265,7 +267,7 @@ object PddScreenParser {
/** 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 {
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null, forFinalConfirmation: Boolean = false): ParsedPddScreen {
val visibleNodes = snapshot.nodes.filter { it.visible }
val visible = visibleNodes.mapNotNull { node ->
val descendants = descendants(node, visibleNodes)
@@ -422,9 +424,12 @@ object PddScreenParser {
)
}
.toList()
.groupBy(VisibleSpecValue::rawText)
.map { (_, sameLabel) ->
sameLabel.minBy { option -> safeOptionRank(option.node, visibleNodes) }
.let { physicalOptions ->
// Keep optionBlock's physical parent/child aggregation in both modes.
// Only final confirmation must detect separate same-label option blocks.
if (forFinalConfirmation) physicalOptions else physicalOptions
.groupBy(VisibleSpecValue::rawText)
.map { (_, sameLabel) -> sameLabel.minBy { option -> safeOptionRank(option.node, visibleNodes) } }
}
if (values.isNotEmpty()) add(VisibleDimension(dimensionKey, heading.label, values))
}
@@ -471,6 +476,7 @@ object PddScreenParser {
else -> SpecPanelType.UNKNOWN
}
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
var unprefixedSummaryCandidates = emptyList<String>()
val unprefixedSummary = if (structuredSelectionPanel) {
val quantity = quantityInputs.single()
val knownColors = (dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text } +
@@ -486,6 +492,7 @@ object PddScreenParser {
knownColors.any { SpecValueNormalizer.summaryHasExactToken(node.label, it) }
}.map { it.label }.distinct()
if (candidates.isNotEmpty()) {
unprefixedSummaryCandidates = candidates
summary = candidates.singleOrNull()
break
}
@@ -574,6 +581,12 @@ object PddScreenParser {
hasQuantityAdjustControls = hasQuantityAdjustControls,
quantityInputCount = quantityInputs.size,
requiredEvidenceSameContainer = requiredEvidenceSameContainer,
finalSelectionSummaries = if (forFinalConfirmation) {
// Use own node labels, not labels propagated into clickable ancestors.
(visibleNodes.map(SnapshotNode::label).filter { label ->
textAliases.selection.selectedPrefixes.any { label.startsWith(it) }
} + unprefixedSummaryCandidates).distinct()
} else emptyList(),
explicitSpecEntryCount = explicitSpecEntries.size,
nestedSpecEntryCount = nestedSpecEntries.size,
bottomPurchaseEntryCount = bottomSpecEntries.size,
@@ -0,0 +1,126 @@
package cn.ilapage.goauto.agent.automation
/** Current-capture evidence only. No clicks, historical selection proof, or whole-screen text fallback. */
internal object PurchaseFinalSpecVerifier {
private fun normalize(value: String) = SpecValueNormalizer.normalizeFinalConfirmation(value)
fun verify(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostic: (String) -> Unit) {
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
fun within(node: SnapshotNode, container: SnapshotNode): Boolean {
val visited = mutableSetOf<String>()
var current: SnapshotNode? = node
while (current != null && visited.add(current.path)) {
if (current.path == container.path) return true
current = current.parentPath?.let(byPath::get)
}
return false
}
fun contained(node: SnapshotNode, container: SnapshotNode) =
node.bounds.width > 0 && node.bounds.height > 0 &&
node.bounds.left >= container.bounds.left && node.bounds.right <= container.bounds.right &&
node.bounds.top >= container.bounds.top && node.bounds.bottom <= container.bounds.bottom
val original = PddScreenParser.parse(snapshot, config, input.goodsId, null, forFinalConfirmation = true)
val fullPanel = boundedQuantityPanel(snapshot).takeIf { original.specPanelOpen }
// #331/#335 sheet ancestry, as represented by SpecPanelFixtures.sheet and taskOptionDedupSheet.
// The inner scroll list is not the full sheet: summary and quantity may be its siblings.
val scoped = fullPanel?.let { panel ->
val nodes = snapshot.nodes.filter { node ->
(within(node, panel) && contained(node, panel)) ||
(node.parentPath == null && node.label.isBlank() && !node.clickable)
}
PddScreenParser.parse(snapshot.copy(nodes = nodes), config, input.goodsId, null, forFinalConfirmation = true)
.takeIf { it.specPanelOpen }
}
val optionScope = if (scoped != null) fullPanel else original.specPanelContainer
val dimensions = (scoped ?: original).dimensions.groupBy(VisibleDimension::key).mapValues { (_, groups) ->
val options = groups.flatMap(VisibleDimension::values).filter { option ->
optionScope != null && within(option.node, optionScope) && contained(option.node, optionScope)
}
// A blank clickable wrapper and its single text child may survive optionBlock separately.
// The #331 size fixture exposes exactly the same rectangle for both. Aggregate only a
// same-value ancestor chain with that physical rectangle, never same-label siblings.
options.groupBy { option ->
options.filter { other -> other.node.bounds == option.node.bounds &&
normalize(other.text) == normalize(option.text) && within(option.node, other.node)
}.minBy { it.node.path.length }.node.path
}.values.map { members ->
members.first().let { first -> first.copy(
available = members.all(VisibleSpecValue::available),
node = first.node.copy(
selected = members.any { it.node.selected },
checked = members.any { it.node.checked },
),
) }
}
}
val summaries = scoped?.finalSelectionSummaries.orEmpty().map { raw ->
// Only the anchored recognized prefix and its optional colon are removed.
val trimmed = raw.trim()
val prefix = config.textAliases.selection.selectedPrefixes.firstOrNull(trimmed::startsWith)
val value = if (prefix == null) trimmed else trimmed.removePrefix(prefix).trimStart().let {
if (it.startsWith(':') || it.startsWith(':')) it.drop(1) else it
}
normalize(value)
}.distinct()
val targets = linkedMapOf("color" to normalize(input.mappedColor), "size" to normalize(input.mappedSize))
.filterValues(String::isNotBlank)
fun reject(dimension: String, stage: String, selected: Boolean = false, conflict: Boolean = false): Nothing {
val detail = "finalSpec;dimension=$dimension;stage=$stage;summaryPresent=${if (summaries.isNotEmpty()) 1 else 0};" +
"selectedFound=${if (selected) 1 else 0};conflict=${if (conflict) 1 else 0}"
diagnostic(detail)
throw PurchaseLiveException("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败;$detail")
}
if (targets.isEmpty()) reject("none", "missing_target")
val selectedProof = mutableSetOf<String>()
targets.forEach { (dimension, target) ->
val options = dimensions[dimension].orEmpty()
val selected = options.filter { it.node.selected || it.node.checked }
// Parsed text already follows the established UI trailing-price separation contract.
// The final-only normalizer removes no additional content from values, input or summary.
val exact = options.filter { normalize(it.text) == target }
if (selected.any { normalize(it.text) != target }) reject(dimension, "selected_conflict", selected = true, conflict = true)
if (exact.size > 1) reject(dimension, "candidate_collision", selected.isNotEmpty(), conflict = true)
if (exact.any { !it.available }) reject(dimension, "unavailable", selected.isNotEmpty(), conflict = true)
if (exact.any { it.node.selected || it.node.checked }) selectedProof += dimension
}
if (summaries.size > 1) reject(targets.keys.first(), "summary_conflict", selectedProof.isNotEmpty(), conflict = true)
val summary = summaries.singleOrNull()
// Evidence only supports color then size with a space. Do not guess reverse order or comma separators.
val expected = targets.values.joinToString(" ")
if (summary != null && targets.size == 2) {
val colors = (dimensions["color"].orEmpty().map { normalize(it.text) } + targets.getValue("color")).distinct()
val sizes = (dimensions["size"].orEmpty().map { normalize(it.text) } + targets.getValue("size")).distinct()
if (colors.any { color -> sizes.any { size ->
"$color $size" == summary && (color != targets["color"] || size != targets["size"])
} }) reject("color", "composition_ambiguity", selectedProof.isNotEmpty(), conflict = true)
}
targets.keys.forEach { dimension ->
if (dimension !in selectedProof && summary != expected) reject(dimension, "missing_proof")
}
}
/** Existing #335 quantity-ancestor form, retaining identity as well as bounds for summary ownership. */
private fun boundedQuantityPanel(snapshot: UiSnapshot): SnapshotNode? {
val visible = snapshot.nodes.filter(SnapshotNode::visible)
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null
val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null
val screenArea = screenWidth.toLong() * screenHeight
val byPath = visible.associateBy(SnapshotNode::path)
var current = visible.singleOrNull {
it.enabled && it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true
} ?: return null
var panel: SnapshotNode? = null
val visited = mutableSetOf(current.path)
while (true) {
val parent = current.parentPath?.let(byPath::get) ?: break
if (!visited.add(parent.path)) return null
val area = parent.bounds.width.toLong() * parent.bounds.height
if (area <= 0 || area >= screenArea || parent.parentPath == null) break
panel = parent
current = parent
}
return panel
}
}
@@ -321,10 +321,7 @@ class PurchaseLiveAutomation(
val submit = finalSubmitTargets(snapshot)
if (submit.size != 1) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "创建订单按钮不是唯一目标,禁止创建订单")
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, input.goodsId, null)
val selected = listOf(input.mappedColor, input.mappedSize).filter(String::isNotBlank)
if (selected.any { value -> screen.selectedSummary?.contains(value) != true && snapshot.nodes.none { it.visible && it.label.contains(value) } }) {
fail("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败")
}
PurchaseFinalSpecVerifier.verify(snapshot, input, panelDiagnostic)
val price = screen.priceCent ?: fail("RULE_NOT_MATCHED", "创建订单前没有读取到商品单价")
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
val quantities = snapshot.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true }.mapNotNull { it.label.toLongOrNull() }
@@ -13,6 +13,20 @@ object SpecValueNormalizer {
fun normalizeSize(value: String): String = normalizeTrailingPrice(value)
/** Final purchase confirmation only: display equivalents, never semantic/content removal. */
fun normalizeFinalConfirmation(value: String): String = buildString {
value.forEach { ch ->
append(when (ch) {
'【', '〔', '[' -> '['
'】', '〕', ']' -> ']'
'(' -> '('
')' -> ')'
in 'A'..'Z', in 'a'..'z', in '0'..'9' -> (ch.code - 0xFEE0).toChar()
else -> if (ch.isWhitespace()) ' ' else ch
})
}
}.replace(Regex(" +"), " ").trim()
/** The same terminal-price grammar as normalization; never reads numbers inside a spec. */
internal fun trailingPriceAmount(value: String): java.math.BigDecimal? =
trailingPrice.find(value)?.value?.replace(currency, "")?.trim()?.toBigDecimalOrNull()
@@ -0,0 +1,241 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import org.junit.Assert.*
import org.junit.Test
/** Final capture only; derived from the existing #331/#332 bounded purchase-sheet fixtures. */
class PurchaseFinalSpecConfirmationTest {
@Test fun `bracket display variants and offscreen color are proven by full panel summary`() {
val snapshot = sheet("已选 [香槟]RW圆领 L码[105-115斤]", colors = listOf("其他颜色"), sizes = listOf("L码[105-115斤]"))
.selected("size/o0/t")
accepts(snapshot, "【香槟】RW圆领", "L码【105-115斤】")
}
@Test fun `summary above scroll area is accepted for scrollable and non scrollable sheets`() {
for (scrollable in listOf(true, false)) {
accepts(sheet("已选 黑色 XL", scrollable = scrollable))
}
}
@Test fun `checked only exact option and physical parent child duplicates provide selection proof`() {
accepts(sheet(null).selected("color/o0/img", checked = true).selected("size/o0/t", checked = true))
}
@Test fun `selected other color or size overrides matching summary and positive target`() {
rejects(sheet("已选 黑色 XL", colors = listOf("黑色", "白色")).selected("color/o0").selected("color/o1"))
rejects(sheet("已选 黑色 XL", sizes = listOf("XL", "L")).selected("size/o1/t"))
}
@Test fun `selected bracket variants are not a conflict`() {
accepts(sheet("已选 [白色] XL", colors = listOf("[白色]")).selected("color/o0").selected("size/o0/t"), "【白色】")
}
@Test fun `distinct physical identical or normalized target options are collisions`() {
for (colors in listOf(listOf("黑色", "黑色"), listOf("【白色】", "[白色]"))) {
rejects(sheet("已选 ${colors[0]} XL", colors = colors).selected("color/o0"), colors[0])
}
}
@Test fun `explicit unavailable target cannot be rescued by summary`() {
rejects(sheet("已选 黑色 XL").mapNodes { if (it.path.contains("color/o0")) it.copy(enabled = false) else it })
}
@Test fun `all required selected proofs take priority over a single non proving summary`() {
accepts(sheet("已选 其他颜色 其他尺码").selected("color/o0/img").selected("size/o0/t"))
rejects(sheet("已选 白色 L", colors = listOf("黑色", "白色"), sizes = listOf("XL", "L"))
.selected("color/o0/img").selected("size/o0/t")) // Known alternative composition is a conflict.
}
@Test fun `selected options retain established parser separation of trailing display price`() {
accepts(sheet("已选 黑色 XL", colors = listOf("黑色 ¥19.60"), sizes = listOf("XL ¥19.60"))
.selected("color/o0/img").selected("size/o0/t"))
rejects(sheet("已选 黑色 XL", colors = listOf("黑色加绒 ¥19.60")).selected("color/o0/img"))
rejects(sheet("已选 黑色 XL", colors = listOf("黑色 ¥19.60", "黑色 ¥20.00")).selected("color/o0/img"))
}
@Test fun `unselected visible options alone and unrelated title cannot prove selection`() {
rejects(sheet(null).addText("r/title", "示例黑色 XL 商品"))
}
@Test fun `panel external prefixed summary is ignored even at same coordinates`() {
rejects(sheet(null).addText("r/fake", "已选 黑色 XL"))
accepts(sheet("已选 黑色 XL").addText("r/fake", "已选 白色 L"))
}
@Test fun `structural ancestry overrides textual path prefix`() {
rejects(sheet(null).addText("r/sheet/fake", "已选 黑色 XL", parent = "r"))
}
@Test fun `whole summary rejects substring sizes colors extra dimensions quantity and unsupported separators`() {
for (summary in listOf("已选 黑色 XL", "已选 黑色 加绒 L", "已选 黑色 L 2件", "已选 黑色,L", "已选 L 黑色")) {
rejects(sheet(summary, sizes = listOf("L")), size = "L")
}
rejects(sheet("已选 [白色+黑色]超值两件装 XL", colors = emptyList()), "【白色】")
}
@Test fun `known candidates making another full composition reject ambiguous summary`() {
rejects(sheet("已选 黑色 加绒 XL", colors = listOf("黑色", "黑色 加绒"), sizes = listOf("加绒 XL", "XL")), size = "加绒 XL")
rejects(sheet("已选 黑色 加绒 XL", colors = listOf("黑色", "黑色 加绒"), sizes = listOf("加绒 XL", "XL")), color = "黑色 加绒")
}
@Test fun `summary permits invisible color despite another unselected visible color`() {
accepts(sheet("已选 黑色 XL", colors = listOf("白色")))
}
@Test fun `required single dimension must equal whole summary and empty pair is never proof`() {
accepts(sheet("已选 黑色"), size = "")
accepts(sheet("已选 XL"), color = "")
rejects(sheet("已选 黑色 XL"), size = "")
rejects(sheet("已选 黑色 XL"), color = "")
rejects(sheet("已选 黑色 XL"), color = " ", size = "")
}
@Test fun `anchored selected prefix variants preserve internal colons commas and whitespace`() {
for (prefix in listOf("已选", "已选 ", "已选:", "已选: ", " 已选: ")) {
accepts(sheet("$prefix 黑色:加绒,款 XL", colors = emptyList()), color = "黑色:加绒,款")
}
accepts(sheet("已选 黑色 加绒 XL", colors = emptyList()), color = "黑色 加绒")
rejects(sheet("已选 黑色 加绒 XL", colors = emptyList()), color = "黑色加绒")
rejects(sheet("已选 黑色:加绒 XL", colors = emptyList()), color = "黑色加绒")
rejects(sheet("已选::黑色 XL"))
accepts(sheet("已选::黑色 XL", colors = emptyList()), color = ":黑色")
}
@Test fun `conflicting scoped summaries reject even both exact options selected`() {
rejects(sheet("已选 黑色 XL").selected("color/o0").selected("size/o0/t").addText("r/sheet/second", "已选 白色 L"))
accepts(sheet("已选 黑色 XL").addText("r/sheet/second", "已选:黑色 XL"))
}
@Test fun `display normalization is narrow and content preserving`() {
accepts(sheet("已选 [白色](加绒) RW12 XL", colors = emptyList()), color = "〔白色〕(加绒) RW12")
rejects(sheet("已选 白色 RW12 XL", colors = emptyList()), color = "白色 rw12")
rejects(sheet("已选 白色 1 XL", colors = emptyList()), color = "白色 ①")
rejects(sheet("已选 白色 XL", colors = emptyList()), color = "白色 ¥19.6")
}
@Test fun `existing structurally recognized unprefixed summary is usable`() {
accepts(prefixlessSheet())
rejects(sheet("黑色 XL")) // No quantity/header relationship in this fixture.
rejects(sheet(null).addText("r/title", "黑色 XL"))
rejects(prefixlessSheet().addText("r/sheet/body/info/second", "黑色 L"))
}
@Test fun `same dimension groups retain earlier selected conflicts and physical candidates`() {
val base = sheet("已选 黑色 XL").addText("r/sheet/body/list/extra/h", "颜色分类")
.addText("r/sheet/body/list/extra/o", "白色")
.mapNodes { when (it.path) {
"r/sheet/body/list/extra/h" -> it.copy(bounds = NodeBounds(20, 1000, 500, 1040), parentPath = "r/sheet/body/list")
"r/sheet/body/list/extra/o" -> it.copy(bounds = NodeBounds(20, 1045, 500, 1100), parentPath = "r/sheet/body/list", clickable = true, selected = true)
"r/sheet/body/list" -> it.copy(bounds = NodeBounds(0, 980, 1080, 2036))
else -> it
} }
rejects(base)
rejects(base.mapNodes { if (it.path.endsWith("extra/o")) it.copy(text = "黑色") else it })
}
@Test fun `collector default still collapses same raw labels and leaves summary behavior unchanged`() {
val snapshot = sheet("已选 黑色 XL", colors = listOf("黑色", "黑色"))
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
val regular = PddScreenParser.parse(snapshot, config, "374", null)
val final = PddScreenParser.parse(snapshot, config, "374", null, forFinalConfirmation = true)
assertEquals(1, regular.dimensions.single { it.key == "color" }.values.size)
assertEquals(2, final.dimensions.single { it.key == "color" }.values.size)
assertEquals(regular.selectedSummary, final.selectedSummary)
assertTrue(regular.finalSelectionSummaries.isEmpty())
}
@Test fun `independently bounded selected options survive absent full panel boundary`() {
val snapshot = sheet(null).selected("color/o0/img").selected("size/o0/t")
.mapNodes { if (it.path == "r/sheet") it.copy(bounds = NodeBounds(0, 0, 1080, 2216)) else it }
PurchaseFinalSpecVerifier.verify(snapshot, input()) { fail(it) }
val noSelections = snapshot.mapNodes { it.copy(selected = false, checked = false) }
.addText("r/sheet/fake", "已选 黑色 XL")
try {
PurchaseFinalSpecVerifier.verify(noSelections, input()) {}
fail("unproven full panel must not supply summary")
} catch (failure: PurchaseLiveException) { assertEquals("PURCHASE_SPEC_NOT_MATCHED", failure.code) }
}
@Test fun `price quantity and address checks remain enforced`() {
rejects(sheet("已选 黑色 XL"), expectedCode = "PURCHASE_PRICE_OUT_OF_RANGE", input = input().copy(maxUnitPriceCent = 1))
rejects(sheet("已选 黑色 XL"), expectedCode = "PURCHASE_QUANTITY_MISMATCH", input = input().copy(quantity = 2))
rejects(sheet("已选 黑色 XL").mapNodes { if (it.path == "r/sheet/body/addr/a/detail") it.copy(text = "示例地址") else it }, expectedCode = "PURCHASE_ADDRESS_UPDATE_FAILED")
}
private fun sheet(
summary: String?, colors: List<String> = listOf("黑色"), sizes: List<String> = listOf("XL"), scrollable: Boolean = true,
): UiSnapshot = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = colors, sizeLabels = sizes, listScrollable = scrollable))
.mapNodes { when (it.path) {
"r/sheet/body/summary" -> it.copy(text = summary, visible = summary != null)
"r/sheet/body/addr/a/detail" -> it.copy(text = ADDRESS)
else -> it
} }
/** Reuses the info-above-list shape from PddProductDetailCollectorTest.prefixlessPanel. */
private fun prefixlessSheet(): UiSnapshot {
val base = sheet("黑色 XL")
val info = base.nodes.single { it.path == "r/sheet/body/qty" }.copy(
path = "r/sheet/body/info", bounds = NodeBounds(0, 770, 1080, 1000), parentPath = "r/sheet/body",
)
return base.mapNodes { when {
it.path == "r/sheet/body/summary" -> it.copy(path = "r/sheet/body/info/summary", parentPath = info.path)
it.path == "r/sheet/body/qty" -> it.copy(path = "r/sheet/body/info/qty", parentPath = info.path)
it.path.startsWith("r/sheet/body/qty/") -> it.copy(path = it.path.replace("body/qty/", "body/info/qty/"), parentPath = "r/sheet/body/info/qty")
else -> it
} }.let { it.copy(nodes = it.nodes + info) }
}
private fun UiSnapshot.mapNodes(transform: (SnapshotNode) -> SnapshotNode) = copy(nodes = nodes.map(transform))
private fun UiSnapshot.selected(suffix: String, checked: Boolean = false) = mapNodes {
if (it.path.endsWith(suffix)) it.copy(selected = !checked, checked = checked) else it
}
private fun UiSnapshot.addText(path: String, text: String, parent: String = path.substringBeforeLast('/')) = copy(nodes = nodes + SnapshotNode(
path, parent, text, null, null, "android.widget.TextView", NodeBounds(384, 791, 1068, 852),
false, false, false, false, true, true,
))
private fun input(color: String = "黑色", size: String = "XL") = PurchaseExecutionInput(
taskId = 374, executionMode = "live", phase = "purchase", url = "https://mobile.yangkeduo.com/goods.html?goods_id=374",
goodsId = "374", mappedColor = color, mappedSize = size, quantity = 1,
minUnitPriceCent = 1, maxUnitPriceCent = 3000, addressSuffix = "_cg374",
)
private fun check(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostics: MutableList<String> = mutableListOf()): FinalConfirmationEvidence {
val driver = object : PurchaseUiDriver {
override fun capture() = snapshot
override fun clickFresh(target: SnapshotNode): FreshActionResult = error("final confirmation must be read only")
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = error("unexpected tap")
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult = error("unexpected input")
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected scoped swipe")
override fun backPurchase(): Boolean = error("unexpected back")
}
return PurchaseLiveAutomation(driver, pause = { error("unexpected pause") }, panelDiagnostic = diagnostics::add)
.finalConfirmation(input, ShippingAddressProof(ADDRESS, "_cg374"))
}
private fun accepts(snapshot: UiSnapshot, color: String = "黑色", size: String = "XL") {
val diagnostics = mutableListOf<String>()
val result = try { check(snapshot, input(color, size), diagnostics) } catch (failure: PurchaseLiveException) {
throw AssertionError("${failure.code}: ${diagnostics.lastOrNull()}", failure)
}
assertEquals(color, result.mappedColor)
}
private fun rejects(snapshot: UiSnapshot, color: String = "黑色", size: String = "XL", expectedCode: String = "PURCHASE_SPEC_NOT_MATCHED", input: PurchaseExecutionInput = input(color, size)) {
val diagnostics = mutableListOf<String>()
try {
check(snapshot, input, diagnostics)
fail("expected $expectedCode")
} catch (failure: PurchaseLiveException) {
assertEquals(expectedCode, failure.code)
if (expectedCode == "PURCHASE_SPEC_NOT_MATCHED") {
val detail = diagnostics.last()
assertTrue(detail, detail.matches(Regex("finalSpec;dimension=(color|size|none);stage=[a-z_]+;summaryPresent=[01];selectedFound=[01];conflict=[01]")))
assertTrue(failure.message.orEmpty(), failure.message.orEmpty().contains(detail))
assertFalse(failure.message.orEmpty().contains(ADDRESS))
assertFalse(failure.message.orEmpty().contains("黑色"))
assertFalse(failure.message.orEmpty().contains("XL"))
}
}
}
private companion object { const val ADDRESS = "示例区示例路374号_cg374" }
}
@@ -8,6 +8,15 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class SpecValueNormalizerTest {
@Test
fun `final confirmation maps only display brackets alphanumerics and whitespace`() {
assertEquals("[白][黑][灰](薄款) RW12 XL", SpecValueNormalizer.normalizeFinalConfirmation(" 【白】〔黑〕[灰](薄款) RW12\t\nXL "))
assertEquals("白色,加绒:XL ¥19.60 ① 2件", SpecValueNormalizer.normalizeFinalConfirmation("白色,加绒:XL ¥19.60 ① 2件"))
assertEquals("xl", SpecValueNormalizer.normalizeFinalConfirmation("xl"))
assertEquals("【白色】 XL", SpecValueNormalizer.normalizeColor("【白色】 XL"))
assertEquals("【白色】 XL", SpecValueNormalizer.normalizeSize("【白色】 XL"))
}
@Test
fun `only trailing price is removed`() {
assertEquals("紫色", SpecValueNormalizer.normalizeColor("紫色 ¥15.78"))
+4 -4
View File
@@ -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: 579f053988e3f67f287ff130f9c24a692d897191
synchronized_at: 2026-10-09T02:06:38Z
wiki_revision: 76854d900b2372ddedd46a2b82b5677d8bf5cb31
synchronized_at: 2026-10-09T07:45:10Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -656,7 +656,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
## Android 重购编排(#367)
实现绑定 `5430dc1`,工单分支尚未合并/安装/发布;无Server/Web、数据库或接口迁移。
实现绑定 `5430dc1`,2026-10-09 已合并 main `e26743c` 并安装集成APK;无Server/Web、数据库或接口迁移,真机批量采购仍待验收。
- `TaskHistoryFragment` 把采购搜索栏入口改为“重购”,发送 PREPARE/CONFIRM/STOP、显示 Service 内存状态;一次确认与逐条摘要由 `RepurchaseState` 提供,不由 Fragment 驱动采购生命周期。
- `RepurchaseBatchDiscovery` 注入既有设备历史查询和服务端HTTP Date时钟,负责全状态/50条页/30天窗口、相邻60秒边界、去重、完整性及重读首页;最多200页,不使用本地历史缓存执行重购。
@@ -667,7 +667,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
## Android 颜色发现诊断追加(#370 第一阶段)
实现绑定 `9e5f09a`,独立工单分支,尚未合并 main;本阶段不修改颜色遍历和采购结果判定。
实现绑定 `9e5f09a`,已合并 main,2026-10-09 随集成版本 `e26743c` 安装;本阶段不修改颜色遍历和采购结果判定。
- `AgentDiagnosticSchema` 升至 v5,在 `agent_diagnostic` 追加可空 INTEGER `color_vertical_swipe_count`、`color_trailing_empty_read_count`、`color_horizontal_incomplete`;对应事件中的两个 Int? 和一个 Boolean?,Boolean 按 0/1 写入。新库包含列,v1–v4 逐级补齐并跳过已存在列,旧行新列为 NULL;不清表,不改独立采购失败快照、队列和 context。
- `PddProductDetailCollector.collectColors` 只基于既有解析帧记录颜色阶段 UP 派发次数、空发现轮次和横向探索三态;不增加抓取、手势或等待。标签及签名只在内存使用,持久化仍为安全聚合。
+36 -5
View File
@@ -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: c4870fd01a4569059fc0afc77179d14fbd286384
synchronized_at: 2026-10-08T10:15:21Z
wiki_revision: f8a5ca861965f5da9bdec2235205f23fb9a00840
synchronized_at: 2026-10-09T07:45:19Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -375,6 +375,8 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
## SYB 采购强制当次规格探测(#215)
以下为 #373 之前的复用行为;#373 分支的限定复用和等待规则见“采购页面一次性交接与导航稳定等待”章节,未安装新版不能按新规则判断。
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务。连续进入第二趟且当前仍有 PDD 商品页或规格面板强证据时复用当前页、不再次打开链接;手动同任务重试,或当前处于 Agent、其他应用及缺少上述强证据时,重新打开任务固化的商品 URL。复用或重开均不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
- 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。
- 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。
@@ -676,7 +678,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
## 支付页安全返回后的核单观察(#365)
实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`,工单分支已实现,未合并 main、未安装或发布;自动化验证不代表真机核单成功率已改善。仅 Android 变化,Admin、Server、数据库和上传状态契约不改。
实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`,已合并 main 并随2026-10-09集成APK安装;自动化验证不代表真机核单成功率已改善。仅 Android 变化,Admin、Server、数据库和上传状态契约不改。
- 只有识别到 PDD 支付页且既有一次 Back 成功后,才记录单调时钟起点。在尚未取得订单/待付款上下文时,允许观察至该起点后约30秒,不因反复采样或上下文缺失重置;不能用 paymentPageObserved 代替成功返回事实。
- 无成功返回,或在原60次内已取得普通订单上下文的路径,保持原预算。返回失败、持续支付页25次、空窗口15次、微信恢复失败和未授权应用等独立失败保持,不保证每种失败等满30秒。
@@ -862,7 +864,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
## 图搜采集部分完成的自动关联资格(#368)
实现基线:工单分支 `fix/368-image-search-partial-link` 提交 `15ccab3`(2026-10-08);尚未合并 main 或发布,生产是否启用以发布记录为准。
实现基线:`15ccab3`(2026-10-08);2026-10-09 已合并 main `e26743c` 并部署122.228.200.167,真实图搜采集业务效果仍待验收。
- 此规则仅作用于 `source=image_search` 的结果后置自动关联。正常 `completed` 沿用原行为,包括 `missing_json=[]`。
- `completed_partial` 仅在 `missing_json` 为可解析、非空字符串数组且每一项精确属于 `reviewCount`、`salesText`、`shopName` 时允许自动关联。三项均为可选描述信息。
@@ -875,7 +877,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
## Android 本设备最后一批重购(#367)
实现绑定 `5430dc1f64fc0a4d8ef124ada7356e21a975151f`(2026-10-08,工单分支 `feat/367-last-batch-repurchase`);未合并 main、未安装或发布,真机批量采购仍待独立授权验收。
实现绑定 `5430dc1f64fc0a4d8ef124ada7356e21a975151f`(2026-10-08,工单分支 `feat/367-last-batch-repurchase`);2026-10-09 已合并 main `e26743c` 并安装集成APK,真机批量采购仍待独立授权验收。
- 采购页搜索栏的“回填”入口替换为“重购”。原回填弹窗、Service 动作和实现保留但不再由该按钮触发;原单条重试、继续采购不改。重购使用原任务的 `/reset`,保留 CG 编号,不使用创建替代任务的 `/retry`。
- 读取本设备近30天全部状态、不继承页面编号/状态筛选;按创建时间倒序、同刻任务ID倒序,从最新记录逐项比较相邻时间,不超过60秒归入同一推算批次。跨50条分页去重,最多200页,结束复核首页;头部或总量变化、日期/页结构异常、查询截断无法证明完整时不启动,不拿部分列表执行。批次时间边界使用独享API客户端的 HTTP Date,不使用手机时钟,缺少有效 Date 拒绝;此轻量方案不保证任意并发删除/归属变动的数据库快照。
@@ -885,3 +887,32 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
- Service 独立串行后台调度观察,真正领取、执行、上传仍由原执行器和租约/任务锁负责。终态且执行锁、活跃记录和Outbox均收尾后才继续。检测新采集或采购工作停止剩余,本轮当前task本身不算新工作。
- 页面和通知栏“停止重购”只停止后续,已提交/执行的任务沿用原流程;切Tab、切PDD、界面重建不中断。单条单调时钟等待上限15分钟,超时只停止编排,不取消任务、不重置或再次下单。进程死亡/服务重建不恢复整轮,原单任务恢复不改。
- 本轮摘要与最小attempt关联仅在内存,不新增地址/树采集或数据库。接口、服务端、Web、权限及 #364 诊断契约不变。自动化回归和APK构建通过不代表真机页面/批量下单已验收。
## 采购页面一次性交接与导航稳定等待(#373 v4)
实现绑定分支 `fix/373-navigation-diagnostics` 提交 `dbf9a5f`,调试版本 `0.9.69-373-fix1`;2026-10-09 尚未合并main、发布或安装该修复版。此节是该提交行为,不代表线上旧版本已生效;替代此前仅凭商品结构复用页面的方式。
- Agent在每次采购执行开始时先取出清空服务进程内的交接凭证。凭证只能来自同任务spec_probe正常完成、结果安全落盘、首次提交返回pending且非replayed,并且本地markUploaded成功之后;失败/重传不生成或延长。缺省没有凭证,不因当前有商品结构就复用。
- 凭证绑定taskId、goodsId、源探测attemptId、本地执行代次、无障碍实例、前台变更序号和探测完成时的单调时钟。仅紧接的同任务同商品purchase可消费一次;源/目标attempt本来不同。有效年龄为0至不足30000ms;慢AI或网络等待超过上限只导致重开链接。30秒是基于15秒心跳的保守复用政策,不是实际AI耗时保证。
- 采集、图搜、当前页采集、回填、恢复执行、导航诊断及已知主动离页操作都会使原交接不可用;服务/无障碍重建不恢复。复用仍要求新鲜PDD商品结构,读取后再次核对同一次已消费凭证的期限、实例、前台与代次,避免慢Binder读取期间过期。人工在同一PDD包内切页可能没有可观察前台变化,不能声称完全识别。
- 不符合复用资格时打开本任务链接,沿用直达NEW_TASK|CLEAR_TASK及异常回退浏览器。等待从该次打开开始,不是给整个重购批次加固定间隔。执行器取得实际ACCEPTED路径;未知路径不当作直达。
- 浏览器路径先观察到受支持浏览器,再允许PDD商品结构进入就绪判断,浏览器出现前的旧PDD帧不放行。直达接受后至少3秒缓冲;浏览器返回PDD后至少3秒缓冲。商品结构还需连续稳定至少1秒、至少3次读取,缓冲与稳定时间可以重叠。就绪后立即继续,不无条件等30秒。
- 商品稳定特征只在内存比较标题和规格入口/面板结构,不以销量、价格或滚动拼单变化为依据。非PDD、空窗口、缺少商品证据或结构变化会打断稳定计时;登录、验证码、风控和无效商品保持明确失败。
- 导航总预算为单调时钟30秒、最多300轮、轮间100ms;耗时系统读取可能超过预算,但返回后检查时限,不再继续点击。浏览器入口重试从点击调用返回后至少间隔1秒;原生点击前在新鲜节点重新校验浏览器与剩余预算。超时返回PDD_DETAIL_ENTRY_FAILED,不继续规格、地址或创建订单步骤。
- 用户明确拒绝新增分享/复制链接操作,并接受本版仅解决跨任务无条件复用与导航抢跑。**稳定商品页不是goodsId身份核验;PDD忽略深链而稳定停在旧页、或无可观察变化的同包人工换页,仍可能无法识别。** 不得将任务携带goodsId当作页面回读,不能用本版宣称绝对排除错误商品。
- 不改服务端/API线协议/数据库、采集器、规格选择、地址与下单逻辑、重购批次发现/停止逻辑;不涉及任何付款动作。仅增加Android对既有结果响应status/replayed的本地解析(replayed=false在服务端可省略)。普通流程不新增原始树、截图、分享URL或页面文本落盘。
## 下单前最终规格复核(#374 v3)
实现绑定 `6f1af70d0d2a4c70c31ba42e05c4321f08c6a78e`(2026-10-09,工单分支 `fix/374-final-spec-confirmation`,基于 main `e26743c`);尚未合并、安装或发布,不含 #373 导航修复,真机采购未验收。
- 仅替换 PurchaseLiveAutomation.finalConfirmation 的最终规格检查,不修改之前的规格选择/verifySummary、地址、单价、数量、提交目标与不可逆边界。没有 Server/Web、API 或数据库变化。
- 最终专用规范化只统一【】〔〕[]、()、全角字母数字与空白;压缩连续空白,不折叠大小写、不改同义词、不用通用 NFKC、不删除括号内容。选项使用解析器既有的规格 text(沿用 UI 尾价分离),目标和摘要不新增尾价或数量删除。
- 复用既有数量输入有界祖先的完整面板形态,但保留真实祖先关系与边界双重归属;滚动列表不等于整个弹层,摘要可位于列表上方/兄弟区域。范围内重新确认面板结构,不将全屏/root 当面板,不用仅坐标重叠的面板外文字。完整范围不可靠时禁用摘要,只使用可独立确认的有界规格滚动区域内选项。
- 最终确认专用解析参数默认 false,普通采集及既有调用不变。仅该路径保留同名独立物理选项;沿用原 optionBlock selected/checked 聚合,并合并同值、同矩形、真实祖先链的同一选项,不吞并同名兄弟候选。同一维度多组统一检查。
- 只检查下发非空维度,先拒绝同维度明确选中非目标、目标不可用、不同实际候选归一到同一目标或已知候选组合歧义;随后当前面板内规范化完全相等且 selected/checked 的目标可证明该维度。没有选中证明但也没有冲突时才用可信摘要,即使其他未选中候选仍可见也允许摘要补证,不使用历史 attempt 证明。
- 摘要仅剥离开头已识别的已选前缀和紧随的一个可选冒号,保留正文标点与内容。无前缀摘要仅沿用已有 quantity/header 结构识别且须属于完整面板。当前双维证据只覆盖“颜色 空格 尺码”,逗号分隔、反序、数量后缀或未知格式不猜测支持;仅一个维度时整段须等于该值,两维均空不作为证明。
- 多个不一致的可信摘要、已知组合歧义均拒绝。单个不匹配的摘要不能补证缺失维度;若所有必需维度已有当前精确选中证明且无上述冲突,则按选中证明优先通过。不声称候选缺失时能证明全部潜在 SKU 唯一性。
- 拒绝仍为 PURCHASE_SPEC_NOT_MATCHED,错误信息与诊断只附固定 dimension/stage/summaryPresent/selectedFound/conflict,不附页面文字、规格原文或地址。原本靠任意可见控件含有规格子串的通过路径已移除。
- 自动化证据:8 个相关套件共366项通过,0失败/错误/跳过;debug构建、release单测源码编译通过。完整单测因未修改的图搜返回测试忙循环而中止,不能宣称全量通过。结构夹具依据为 SpecPanelFixtures.sheet/taskOptionDedupSheet 及 PddProductDetailCollectorTest.prefixlessPanel;未执行真实采购,不以合成测试代替真机效果。
+48 -3
View File
@@ -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: 6ca0b35cb1e8058b2930a9a01029822af8b571d2
synchronized_at: 2026-10-08T07:10:52Z
wiki_revision: 01a3a9cd968510c9435ec1fb2be0040fe17d0282
synchronized_at: 2026-10-09T07:45:33Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -312,7 +312,7 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
## 采购失败现场诊断部署与保留(#364)
实现绑定 `4581ee5`;本节为新版本部署要求,不表示线上已迁移/发布。#365/#366 为Android局部行为,#366仅②编辑框就绪/hint保护已交付,①地址列表识别仍待现场证据。
实现绑定 `4581ee5`;2026-10-09 已随 main `e26743c` 完成线上迁移及发布,见下方发布记录。#365/#366 为Android局部行为,#366仅②编辑框就绪/hint保护已交付,①地址列表识别仍待现场证据。
1. 先取得目标环境的追加迁移及发布授权,核对唯一待执行版本 `1791400000000_purchase_failure_snapshot.go`,建立独立私有诊断表,再启动配套Server/Web。专用摘要/ZIP下载注册到既有启动权限对账,只允许管理员;对账会维护完整API目录及采购员既有权限,不是只写两条接口。
2. Server接收与权限验证就绪后再安装新Agent。旧Server不支持新诊断接口;不要把“本机保存成功”当作“已上传”。仅合并main不启动服务、不执行线上迁移、不自动安装APK。
@@ -320,3 +320,48 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
4. 管理员从采购详情执行记录下载ZIP;无现场、未截取、partial、上传中/失败应按实际结果区分。排错日志仅固定错误分类,禁止复制ZIP内容到日志、工单、Wiki或消息;分析只摘必要脱敏结构。沿用现有Agent HTTPS/显式HTTP例外,HTTP上传有个人数据明文风险。
5. 代码回滚时保留追加表和已存数据,不降级或删除手机数据库来恢复;回滚到尚不识别schema v4的旧Agent须先单独评估,不承诺可直接覆盖安装。保留期限清理须有仍在运行的相应新版维护组件。
6. 本机换机后的数据库经用户授权初始化于 `127.0.0.1:3308/goauto`,配置仍来自仓库根目录已忽略的config.yaml;已完成baseline迁移及#364幂等、容量、权限验证,未启动业务服务或连接线上。不是将历史3307运行说明套用于所有环境;实际Supervisor目录/发布按目标环境另核对。
## 2026-10-08 换机后本地 Supervisor 联调配置(#364 后续运行准备)
本节是当前电脑的配置,优先于上文历史 D:/supervisor、3307 和 main-runtime 路径;不改变线上拓扑。
- Supervisor 位于 `D:/supervisord`,主配置 `supervisord.conf` 通过 `[include] files=programs/goauto.conf` 引入两个实例:`goauto-admin-api` 和 `goauto-admin-ui`。两者 `autostart=false`、`autorestart=true`,添加/重读配置不等于批准启动所有服务;只对目标实例操作,不重启整个Supervisor。
- 独立运行目录 `D:/OPC/goauto-runtime` 干净地固定在已合并main的 `e450ac28989de6827328b411e4bdc050dded9ab3`,用detached HEAD避免与开发分支混淆。根仓库 `D:/OPC/goauto` 仍保留用户旧分支和未提交资料,不在其中切main或覆盖。
- 两实例读取 `D:/OPC/goauto/config.yaml`;本地MySQL为127.0.0.1:3308/goauto,API端口8010,Web端口9527。后端调用运行目录的 `scripts/start-server.ps1 -ConfigPath D:/OPC/goauto/config.yaml -SkipMigration`,前端调用 `scripts/start-web.ps1 -ConfigPath D:/OPC/goauto/config.yaml`。后端仍会校验/确保数据库存在并执行既有启动权限对账;SkipMigration不是只读启动。
- 当前电脑默认go为1.24且GOTOOLCHAIN=local,继承的GOSUMDB=off又会阻止自动下载1.26.5;仅后端实例的PowerShell命令把已安装 `C:/Users/Qiu/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.5.windows-amd64/bin` 前置到进程PATH,并设置GOTOOLCHAIN=local。已用该环境构建验证;未更改全局环境、校验策略或项目go.mod。此绝对路径是本机配置,其他电脑须核实已安装工具链,不照抄。
- 前端依赖属于运行目录自身,按锁文件安装,不链接其他worktree的node_modules。若旧残留依赖出现ERR_MODULE_NOT_FOUND且普通安装未补齐,先用 `node -e "import('vite')"` 复现,再在本目录按锁文件重新安装;不通过修改业务代码或升级依赖绕过。
- 手动启动顺序为API就绪后Web;Web原脚本最多等待API健康60秒,并将/api同源代理到本机8010。浏览器本机入口 `http://127.0.0.1:9527/`,Agent使用电脑实际局域网IP的8010端口,不把127.0.0.1配置给手机。
- 日志为 `D:/supervisord/goauto-admin-api.log`、`D:/supervisord/goauto-admin-ui.log`。检查依赖、端口和实际子进程环境;不能只因Supervisor显示Running就宣称HTTP就绪。
- 新库预置SYB每小时同步与Yeeke同步status=2(启用);蝦皮规格自动匹配、异常规格AI解析status=1(关闭)。本轮只读检查,未改任务状态;用户手动启动API后既有调度会运行,真实外部访问须结合本地凭据/配置确认,不能把本地环境当作自动隔离的沙箱。
- 配置动作未新增迁移、未部署线上、未安装APK;用户随后确认亲自点击了启动。健康/页面验证记录在工单,不将健康检查冒充登录、诊断下载或真机端到端验收。
## 2026-10-09 集成发布与 Android 签名连续性(#364/#365/#366/#367/#368/#370)
- 集成源码 main `e26743c817275137f7dc8a3659461d552371b9e3`。122.228.200.167 当前发布目录 `/home/goauto/releases/20261009-e26743c-integrated`,旧目录 `/home/goauto/releases/20261007-0138da8-359` 保留供代码回退。用户明确要求线上有采集/采购执行时不得重启;迁移前及切换前相关运行计数均为0,没有取消、重置或发起业务。
- 已授权追加迁移 `1791400000000_purchase_failure_snapshot.go`、启动权限对账及发布。私有备份 `/home/goauto/backups/20261009-integrated/database.sql` 目录0700/文件0600;只新增诊断表,回读LONGTEXT/LONGBLOB及attempt唯一索引通过。定时任务启停、Cron与参数摘要前后一致。
- Server SHA256 `ae7e479e94db0485661665306d0d0306ca5c3cd4019694991c29b64d113eb637`,运行进程二进制一致;新Web入口SHA256 `242158568fb3ee9c51e2e16574abf0a1b6b5b692ae167ec78bcb957cdf5e7434`。根入口、index、login、syb-products入口、10项JS/CSS、健康与验证码通过。Nginx实际读取用户可读性切换前确认;保留旧hashed资源与config/static/temp/var。没有更改Nginx配置;未认证诊断接口401、现有非管理员凭据403。
- 本次自动化验证为Android 274项受影响测试、Server任务/采购/商品/规格/权限/中间件/迁移相关包、Web 6项模拟API页面测试及三端构建。未执行真实采购或付款;管理员真实诊断下载及完整失败现场上传仍待业务验收。#366仅环节②,#370仅第一阶段诊断,不能视为剩余根因修复已完成。
- Android集成APK保持 `0.9.69-370-diag1 / versionCode 82`,不能只凭版本号区分旧包;本次最终APK SHA256 `68d7caf9ae7ee5808783056f4f1591266746197b520048fa64c89365723edef7`。2026-10-09已覆盖安装到用户指定手机,安装后文件哈希一致、配置及设备身份前后摘要一致、进程启动存在,没有卸载或清数据。
- 换机生成的调试签名与旧机不同会导致 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`;不可为更新擅自卸载。用户提供的原调试签名已与手机证书核对一致,私有文件位于 `D:/OPC/goauto/debug.keystore`,仅在本地Git排除规则中忽略,绝不提交。应另行私密备份,不能重新生成替代。
- 当前Windows用户级 `C:/Users/Qiu/.gradle/init.d/goauto-debug-signing.gradle` 使用Android Components的 `finalizeDsl`,仅对applicationId `cn.ilapage.goauto.agent` 的debug签名指定原文件;其他项目及release配置不变。普通Gradle调试构建自动生效,文件缺失则明确失败,不退回新签名。更换电脑或GRADLE_USER_HOME时需带入相同私有文件及配置;不要把本机设置当作仓库内或其他电脑已配置。构建后用signingReport与apksigner复核,再以install -r更新。
## Android 商品导航诊断(#373,诊断阶段)
- 分支实现 `2c58d58`,调试包 `0.9.69-373-nav-diag3`;尚未合并 main,不代表旧商品页复用或旧页排除已修复。正式采购打开路径保持原样,新增 `PRODUCT_NAVIGATION` 结构诊断至既有 SQLite `agent_diagnostic`,区分 DIRECT/BROWSER 的 ACCEPTED/REJECTED/EXCEPTION,仅记录异常类名及耗时,沿用任务、attempt、device、phase、rule hash 关联和原50条/7天保留规则,无数据库升级。ACCEPTED只表示startActivity返回,不表示目标商品已加载。
- 仅debug包包含 `cn.ilapage.goauto.agent/.diagnostics.NavigationProbeReceiver`,要求发送者具有 `android.permission.DUMP`(ADB shell/system),release清单不含该入口。输入仅数字goodsId及direct/browser,不接受任意URL、脚本或购买动作。必须先获得指定设备覆盖安装及只打开商品页的授权,确认无业务任务运行;保留原签名用install -r,禁止为安装清数据或卸载。
- Agent主界面启动前台服务且无障碍就绪后,通过ADB执行 `adb -s <设备地址> shell am broadcast -n cn.ilapage.goauto.agent/.diagnostics.NavigationProbeReceiver --es goodsId <数字商品ID> --es mode direct`;浏览器对照使用 `--es mode browser`。忙碌/服务未就绪时拒绝、不排队,不能用取消或重置业务任务强行取得设备。
- 探针占用现有设备互斥锁,预算20秒/最多201轮;耗时Binder读取可能越过观察预算,但其后不再派发导航/点击,不能声称能强制中断系统读取。服务或无障碍实例失效、线程中断、登录/风控/验证码时停止。只打开商品链接,在浏览器中最多尝试3次唯一的精确“打开拼多多APP”/“打开拼多多 App”且节点自身可点击的入口;实际派发前再次校验包、窗口、节点与存活条件,无祖先/坐标/手势兜底。PDD内只读,不调用采购执行器、不进入规格选择/改地址/下单/付款,不创建/重试服务器任务。
- 临时探针输出通过 `adb -s <设备地址> logcat -d -s GoAutoNavProbe:I '*:S'` 读取;只包含随机runId、路径/固定结果、相对毫秒、节点计数、窗口id、布尔证据及仅在内存比较所得标题same/different/unknown,不保存标题/哈希/URL/页面原文/原始树/截图,不写业务数据库。别用logcat -t的全局最后几行冒充完整诊断;若缓冲已覆盖须如实记录。禁止导出完整无过滤系统日志。
- `legacy_would_accept`模拟现有两次商品结构命中,始终标注identityVerified=false;窗口变化、标题变化、商品结构成立都不是目标goodsId证明。shell触发探针与后台任务的系统启动许可可能不同;人工指定browser只验证浏览器路径,不证明当时自动回退的异常原因。正常/失败对照及真实后台时序仍须以任务绑定诊断和人工确认核验。
- 自动化验证应包含debug专用探针单测与 `:app:compileReleaseUnitTestKotlin`;探针测试放 `src/testDebug`,不得在共享 `src/test` 引用debug-only类。构建使用原私有签名,安装后回读包版本和APK哈希,并比较设置/设备身份摘要。
## #373 v4 修复版构建与验证(2026-10-09)
- 源码分支 `fix/373-navigation-diagnostics`,实现提交 `dbf9a5f4606529189e57b6c266449070d5001340`。APK版本 `0.9.69-373-fix1 / code82`,与前一导航诊断版不同;未安装修复版、未合并main、未发布线上,不需要Admin迁移或服务重启。
- APK SHA256 `171f4b12395e2940ce873b62ea394799f521d1a0997f6aaf7582a123c24795e8`;仍使用用户原签名,证书SHA256 `bf86d7465c6092be74ce9c4187eb30c9a7d045c3b91891a547e2598429766dec`。后续经安装授权用install -r保留数据,不能为覆盖擅自卸载。相同versionCode不能证明文件相同,应核对完整版本和哈希。
- 主Agent独立执行相关测试451项(23套件)零失败/错误,compileReleaseUnitTestKotlin与assembleDebug通过;规格及代码质量两轮独立只读审查通过。覆盖一次性交接/首传回放/上传标记失败/慢读取过期、浏览器出现前旧帧、diag3时序、3秒缓冲/1秒稳定、慢Binder点击冷却与超时零后续动作,以及采购、采集、原诊断相关回归。不是全量测试或真机采购验收。
- 修复版的真实采购/重购未执行,页面身份识别限制见业务规则。原debug导航探针依然用于旧判据时序取证,不把其legacy_would_accept日志冒充新版执行器通过证据;新版导航就绪由实际采购执行器及其纯策略判断。
- 诊断仍沿用已有本地记录;无需新schema、服务端权限或接口。运维排查先分清实际DIRECT/BROWSER路径、是否发生浏览器前旧页抢跑、是否缓冲/稳定超时,以及复用凭证是否有效;不能仅因startActivity接受就认定商品已加载。