Compare commits

..
Author SHA1 Message Date
QiuSW b306f417d5 docs(agent): sync in-place retry contract (#225) 2026-09-05 16:50:01 +08:00
QiuSW 8f5ff4525e docs(purchase): sync retry business rule (#225) 2026-09-05 16:50:00 +08:00
QiuSW 9124e92ed6 fix(agent): retry purchases in place (#225) 2026-09-05 16:45:52 +08:00
QiuSW 095dbfacbf fix(android): retain verified spec selections (#224) 2026-09-05 16:33:24 +08:00
QiuSW cdcb6930d0 fix(android): revalidate offscreen selected specs (#224) 2026-09-05 16:21:01 +08:00
QiuSW d84f6ddf21 fix(purchase): reuse validated probe mappings (#223) 2026-09-05 15:21:17 +08:00
QiuSW f40331189b fix(android): wait for stable PDD product page (#222) 2026-09-05 12:40:19 +08:00
QiuSW 2895f3d72d fix(purchase): advance past spec confirmation (#220) 2026-09-05 10:54:02 +08:00
QiuSW e4051ed8df feat(purchase): reuse probed PDD page (#219) 2026-09-05 10:34:35 +08:00
QiuSW 5859a819c8 feat(agent): 显示采购结果气泡 (#218) 2026-09-05 10:01:21 +08:00
QiuSW 8d2e61bb72 fix(purchase): 重建 Agent 重试任务 (#217) 2026-09-05 09:06:41 +08:00
QiuSW 96cc2ab700 docs(purchase): 更新 Agent 重试契约 (#217) 2026-09-05 09:03:07 +08:00
QiuSW bd386cfae4 fix(syb): 修正规格颜色尺码反序 (#216) 2026-09-04 19:33:35 +08:00
QiuSW c09ddc8474 feat(purchase): 强制真机探测规格后匹配 (#215) 2026-09-04 19:03:16 +08:00
QiuSW 683aa9c671 fix(agent): 验证采购规格点击并锁定面板滚动 (#214) 2026-09-04 18:23:47 +08:00
QiuSW 1ac50064bd fix(syb): 回填历史店铺标准化键 (#213) 2026-09-04 10:43:36 +08:00
QiuSW 8c76e0932b fix(syb): 冻结同步店铺快照 (#212) 2026-09-04 10:00:48 +08:00
67 changed files with 2976 additions and 558 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 52
versionName = "0.9.39"
versionCode = 60
versionName = "0.9.47"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -49,6 +49,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
screenPolicyHandler.removeCallbacks(screenPolicyRefresh)
screenPolicyHandler.post(screenPolicyRefresh)
}
@@ -18,6 +18,8 @@ import android.util.Log
import android.view.Display
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import cn.ilapage.goauto.agent.ui.PurchaseResultBubbleController
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePresentation
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
@@ -32,6 +34,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
ActivityEvidenceTracker { packageName, className -> isDeclaredActivity(packageName, className) }
}
private var accessibilityButtonCallback: AccessibilityButtonController.AccessibilityButtonCallback? = null
private val purchaseResultBubble by lazy {
PurchaseResultBubbleController(this) { currentPackage() == PDD_PACKAGE }
}
override fun onServiceConnected() {
serviceInfo = serviceInfo.apply {
@@ -48,18 +53,19 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
val packageName = event.packageName?.toString()
if (packageName != null && packageName != lastForegroundPackage) {
lastForegroundPackage = packageName
val foregroundPackage = event.packageName?.toString()
if (foregroundPackage != null && foregroundPackage != lastForegroundPackage) {
lastForegroundPackage = foregroundPackage
foregroundRevision.incrementAndGet()
}
if (packageName == PDD_PACKAGE) lastPddForegroundAt.set(SystemClock.elapsedRealtime())
activityTracker.observe(packageName, event.className?.toString())
if (foregroundPackage == PDD_PACKAGE) lastPddForegroundAt.set(SystemClock.elapsedRealtime())
activityTracker.observe(foregroundPackage, event.className?.toString())
}
}
override fun onInterrupt() = Unit
override fun onDestroy() {
dismissPurchaseResultBubble()
unregisterAccessibilityButton()
if (instance === this) instance = null
super.onDestroy()
@@ -71,6 +77,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
runCatching {
val callback = object : AccessibilityButtonController.AccessibilityButtonCallback() {
override fun onClicked(controller: AccessibilityButtonController) {
dismissPurchaseResultBubble()
AccessibilityButtonPolicy.handleClick(::openAgentPreservingTab)
}
}
@@ -115,6 +122,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
).restore(packageName, timeoutMillis)
fun openAgentPreservingTab(): Boolean = runCatching {
dismissPurchaseResultBubble()
startActivity(
android.content.Intent(this, cn.ilapage.goauto.agent.MainActivity::class.java).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP or android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP)
@@ -123,6 +131,14 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
true
}.getOrDefault(false)
fun showPurchaseResultBubble(presentation: PurchaseResultBubblePresentation) {
purchaseResultBubble.show(presentation)
}
fun dismissPurchaseResultBubble() {
purchaseResultBubble.dismiss()
}
override fun visibleTexts(): List<String> {
val root = rootInActiveWindow ?: return emptyList()
val texts = mutableListOf<String>()
@@ -333,31 +349,32 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
if (bounds.width() < 2 || bounds.height() < 2 || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return FreshActionResult.FAILED
}
val path = Path().apply {
moveTo(bounds.centerX().toFloat(), bounds.centerY().toFloat())
}
val completed = AtomicBoolean(false)
val latch = CountDownLatch(1)
val queued = dispatchGesture(
GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 80)).build(),
object : GestureResultCallback() {
override fun onCompleted(gestureDescription: GestureDescription?) {
completed.set(true)
latch.countDown()
}
return if (dispatchCenterTap(bounds)) FreshActionResult.SUCCESS else FreshActionResult.FAILED
}
override fun onCancelled(gestureDescription: GestureDescription?) {
latch.countDown()
}
},
null,
)
if (!queued) return FreshActionResult.FAILED
return if (latch.await(1_500, TimeUnit.MILLISECONDS) && completed.get()) {
FreshActionResult.SUCCESS
} else {
FreshActionResult.FAILED
override fun tapSpecFresh(target: SnapshotNode): FreshActionResult {
val width = resources.displayMetrics.widthPixels
val height = resources.displayMetrics.heightPixels
if (!PurchaseSpecGesturePolicy.isAllowed(target, width, height)) return FreshActionResult.FAILED
val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND
val candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node ->
val bounds = Rect().also(node::getBoundsInScreen)
if (node.isVisibleToUser && node.isEnabled &&
node.preferredOrDescendantLabel() == target.label &&
node.className?.toString() == target.className &&
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) <= 32 &&
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32
) candidates += node
}
if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND
if (candidates.size != 1) return FreshActionResult.AMBIGUOUS
val bounds = Rect().also(candidates.single()::getBoundsInScreen)
val freshTarget = target.copy(bounds = NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom))
if (!PurchaseSpecGesturePolicy.isAllowed(freshTarget, width, height)) {
return FreshActionResult.FAILED
}
return if (dispatchCenterTap(bounds)) FreshActionResult.SUCCESS else FreshActionResult.FAILED
}
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
@@ -407,7 +424,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
) matches += node
}
if (matches.size != 1) return false
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = false)
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = true)
}
override fun backPurchase(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK)
@@ -617,6 +634,28 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
}
private fun dispatchCenterTap(bounds: Rect): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || bounds.width() < 2 || bounds.height() < 2) return false
val path = Path().apply { moveTo(bounds.centerX().toFloat(), bounds.centerY().toFloat()) }
val completed = AtomicBoolean(false)
val latch = CountDownLatch(1)
val queued = dispatchGesture(
GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 80)).build(),
object : GestureResultCallback() {
override fun onCompleted(gestureDescription: GestureDescription?) {
completed.set(true)
latch.countDown()
}
override fun onCancelled(gestureDescription: GestureDescription?) {
latch.countDown()
}
},
null,
)
return queued && latch.await(1_500, TimeUnit.MILLISECONDS) && completed.get()
}
private fun walk(node: AccessibilityNodeInfo, visit: (AccessibilityNodeInfo) -> Unit) {
visit(node)
for (index in 0 until node.childCount) node.getChild(index)?.let { walk(it, visit) }
@@ -36,6 +36,49 @@ class PurchaseLiveAutomation(
private var submitAttempted = false
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
private set
/**
* Advances an already verified spec selector to the order confirmation
* page. Only the selector's unique exact confirm button is clickable; an
* order-submit or payment control can never satisfy this transition.
*/
fun advanceToOrderConfirmation() {
var snapshot = driver.capture()
pageProblem(snapshot)
if (orderConfirmationReady(snapshot)) return
if (snapshot.packageName != PDD_PACKAGE) {
fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前不是拼多多规格页面,未创建订单")
}
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
if (screen.specPanelType !in setOf(SpecPanelType.NORMAL_SCROLLABLE, SpecPanelType.NON_SCROLLABLE_CONFIRMATION)) {
fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前规格面板不能安全确认,未创建订单")
}
val aliases = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR.textAliases.specPanel.confirmAliases
.map { it.replace(" ", "") }
.toSet()
val targets = snapshot.nodes.filter { node ->
node.visible && node.enabled && node.clickable && node.label.replace(" ", "") in aliases
}.distinctBy { it.path }
if (targets.isEmpty()) {
fail("PURCHASE_SPEC_CONFIRM_TARGET_MISSING", "没有找到唯一的规格确认按钮,未创建订单")
}
if (targets.size > 1) {
fail("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", "规格确认按钮不唯一,未创建订单")
}
when (driver.clickFresh(targets.single())) {
FreshActionResult.SUCCESS -> Unit
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", "规格确认按钮不唯一,未创建订单")
else -> fail("PURCHASE_SPEC_CONFIRM_CLICK_FAILED", "规格确认按钮点击失败,未创建订单")
}
repeat(SPEC_CONFIRMATION_MAX_SAMPLES) {
pause(SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS)
snapshot = driver.capture()
pageProblem(snapshot)
if (orderConfirmationReady(snapshot)) return
}
fail("PURCHASE_SPEC_CONFIRMATION_UNCONFIRMED", "规格确认后没有进入订单确认页,未创建订单")
}
fun updateShippingAddress(addressSuffix: String): ShippingAddressProof {
if (!addressSuffix.matches(Regex("^_cg[1-9][0-9]*$"))) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "采购任务的地址标记无效")
var snapshot = driver.capture()
@@ -407,6 +450,13 @@ class PurchaseLiveAutomation(
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
)
private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean {
if (snapshot.packageName != PDD_PACKAGE) return false
if (snapshot.nodes.any { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) }) return true
return PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null).specPanelType ==
SpecPanelType.ORDER_CONFIRMATION
}
/** A post-submit navigation target is allowed only when it is one exact, non-payment PDD order-detail entry. */
private fun orderDetailEntryTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
snapshot,
@@ -483,5 +533,7 @@ class PurchaseLiveAutomation(
const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 3
const val ORDER_RESULT_SCROLL_SAMPLE_INTERVAL = 15
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
const val SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS = 100L
}
}
@@ -11,6 +11,12 @@ interface PurchaseUiDriver {
reason = FreshClickReason.UNKNOWN,
)
fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult
/**
* Reacquires and center-taps a target that the purchase parser has already
* accepted as either a safe spec entry or one exact spec value. Callers
* must still verify the semantic postcondition from a fresh snapshot.
*/
fun tapSpecFresh(target: SnapshotNode): FreshActionResult = FreshActionResult.FAILED
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
@@ -20,6 +26,21 @@ interface PurchaseUiDriver {
fun bringPddToForeground(): Boolean = false
}
internal object PurchaseSpecGesturePolicy {
private val forbiddenLabels = listOf(
"提交订单", "创建订单", "确认订单", "确认购买", "支付", "付款",
"收货地址", "修改地址", "保存地址",
)
fun isAllowed(target: SnapshotNode, screenWidth: Int, screenHeight: Int): Boolean {
val label = target.label.replace(" ", "")
return target.visible && target.enabled && label.isNotBlank() &&
forbiddenLabels.none(label::contains) &&
target.bounds.width >= 2 && target.bounds.height >= 2 &&
target.bounds.centerX in 0 until screenWidth && target.bounds.centerY in 0 until screenHeight
}
}
data class PurchaseExecutionInput(
val taskId: Long,
val executionMode: String,
@@ -62,6 +83,10 @@ class PurchaseRehearsalExecutor(
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
val live = PurchaseLiveAutomation(driver, pause)
for (action in rule.actions) {
// spec_probe already opened this task's URL and the server reserves
// the device while matching. Skip the whole navigation action,
// including its configured wait/swipe hooks, in phase two.
if (input.phase == "purchase" && action.type == PurchaseActionType.OPEN_PRODUCT) continue
stepChanged(action.type.wireName)
val failure = when (action.type) {
PurchaseActionType.OPEN_PRODUCT -> openProduct(input, action)
@@ -80,6 +105,7 @@ class PurchaseRehearsalExecutor(
null
}
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try {
live.advanceToOrderConfirmation()
addressProof = live.updateShippingAddress(input.addressSuffix)
null
} catch (error: PurchaseLiveException) {
@@ -187,10 +213,20 @@ class PurchaseRehearsalExecutor(
var clickAttempted = false
var nextClickPoll = 0
var lastClickReason = FreshClickReason.UNKNOWN
var stableEvidenceReads = 0
var pddForegroundObserved = false
repeat(OPEN_PRODUCT_POLL_LIMIT) { poll ->
val snapshot = driver.capture()
pageProblem(snapshot)?.let { return it }
if (snapshot.packageName == PDD_PACKAGE) return null
if (snapshot.packageName == PDD_PACKAGE) {
pddForegroundObserved = true
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
stableEvidenceReads = if (screen.hasPurchaseProductEvidence()) stableEvidenceReads + 1 else 0
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) return null
pause(OPEN_PRODUCT_POLL_MILLIS)
return@repeat
}
stableEvidenceReads = 0
val candidates = snapshot.nodes.filter { it.visible && it.enabled && it.label in aliases }
if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一")
if (candidates.size == 1 && poll >= nextClickPoll) {
@@ -208,6 +244,9 @@ class PurchaseRehearsalExecutor(
}
pause(OPEN_PRODUCT_POLL_MILLIS)
}
if (pddForegroundObserved) {
return failure("PDD_DETAIL_ENTRY_FAILED", "打开拼多多后未识别到稳定商品页面")
}
if (clickAttempted) {
val message = when (lastClickReason) {
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> "打开拼多多入口发生变化"
@@ -221,14 +260,20 @@ class PurchaseRehearsalExecutor(
}
private fun verifyProduct(input: PurchaseExecutionInput): PurchaseExecutionOutcome? {
repeat(50) {
var stableEvidenceReads = 0
repeat(PRODUCT_PAGE_POLL_LIMIT) {
val snapshot = driver.capture()
pageProblem(snapshot)?.let { return it }
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
if (screen.hasPurchaseProductEvidence()) {
return recoverSoldOut(input, screen)
stableEvidenceReads++
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) {
return recoverSoldOut(input, screen)
}
} else {
stableEvidenceReads = 0
}
pause(100)
pause(OPEN_PRODUCT_POLL_MILLIS)
}
return failure("PDD_DETAIL_ENTRY_FAILED", "没有进入拼多多商品页面")
}
@@ -297,6 +342,7 @@ class PurchaseRehearsalExecutor(
entryReadyWaitPolls++
screen = currentScreen(input)
}
val beforeSignature = specActionSignature(screen)
val click = driver.clickFreshDetailed(requireNotNull(target))
when (click.result) {
// The parser already narrowed to a single semantic candidate; the
@@ -306,18 +352,94 @@ class PurchaseRehearsalExecutor(
SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口点击目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
)
else -> Unit
}
var wait = waitForSpecPanel(input, beforeSignature)
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
}
when (driver.tapSpecFresh(requireNotNull(target))) {
FreshActionResult.AMBIGUOUS -> return failure(
SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
)
FreshActionResult.SUCCESS -> Unit
else -> return failure(SPEC_ENTRY_CLICK_FAILED, click.reason.specEntrySubreason())
else -> return failure(
SPEC_ENTRY_CLICK_FAILED,
click.reason.specEntrySubreasonAfterGestureFailure(),
)
}
repeat(30) {
screen = currentScreen(input)
panelDiagnostic(panelEvidence(screen))
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
screen.problem?.let { return failure(it.code, it.message) }
if (screen.specPanelOpen) return null
pause(100)
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
}
return failure(SPEC_PANEL_NOT_OPENED, "点击后未识别到商品规格面板 [${panelEvidence(screen)}]")
return failure(
SPEC_ENTRY_CLICK_NO_EFFECT,
"规格入口无障碍点击和受控手势后页面均无变化 [${panelEvidence(wait.screen)}]",
)
}
private data class SpecPanelWait(
val screen: ParsedPddScreen,
val opened: Boolean,
val changed: Boolean,
val failure: PurchaseExecutionOutcome? = null,
)
private data class SpecActionNodeSignature(
val path: String,
val className: String?,
val bounds: NodeBounds,
val clickable: Boolean,
val scrollable: Boolean,
val selected: Boolean,
val checked: Boolean,
val enabled: Boolean,
val visible: Boolean,
)
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
var last = currentScreen(input)
var changed = false
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
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
pause(SPEC_SELECTION_POLL_MILLIS)
}
return SpecPanelWait(last, false, changed)
}
/** In-memory only; the signature is never logged, persisted, or uploaded. */
private fun specActionSignature(screen: ParsedPddScreen): List<SpecActionNodeSignature> = screen.sourceNodes.map { node ->
SpecActionNodeSignature(
node.path,
node.className,
node.bounds,
node.clickable,
node.scrollable,
node.selected,
node.checked,
node.enabled,
node.visible,
)
}
private fun panelEvidence(screen: ParsedPddScreen): String =
@@ -359,6 +481,12 @@ class PurchaseRehearsalExecutor(
else -> "unknown"
}
private fun FreshClickReason.specEntrySubreasonAfterGestureFailure(): String = when (this) {
FreshClickReason.SUCCESS -> "gesture_failed_after_action_click_no_effect"
FreshClickReason.UNKNOWN -> "gesture_failed_after_unclassified_action_result"
else -> specEntrySubreason()
}
private fun selectSpecs(
input: PurchaseExecutionInput,
rule: PurchaseRule,
@@ -386,44 +514,39 @@ class PurchaseRehearsalExecutor(
target: String,
selectionProofs: MutableMap<String, ExactSpecSelectionProof>,
): PurchaseExecutionOutcome? {
var lastClickReason = FreshClickReason.UNKNOWN
var clickSucceeded = false
repeat(SPEC_SELECTION_CLICK_ATTEMPTS) {
val lookup = locateExactSpec(input, dimension, target)
lookup.failure?.let { return it }
val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node))
lastClickReason = outcome.reason
if (outcome.result == FreshActionResult.AMBIGUOUS) {
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")
}
clickSucceeded = clickSucceeded || outcome.result == FreshActionResult.SUCCESS
val initial = currentScreen(input)
initial.problem?.let { return failure(it.code, it.message) }
if (rememberExactSelection(initial, dimension, target, selectionProofs)) return null
// PDD rerenders the remaining options after a color selection. A
// stale node can report NOT_FOUND/FAILED even when the exact option
// was selected. Always verify the observable selected state before
// deciding whether to refind and retry the same exact target.
val verifyPolls = if (outcome.result == FreshActionResult.SUCCESS) {
SPEC_SELECTION_SUCCESS_VERIFY_POLLS
} else {
SPEC_SELECTION_FAILED_VERIFY_POLLS
}
repeat(verifyPolls) {
val refreshed = currentScreen(input)
refreshed.problem?.let { return failure(it.code, it.message) }
if (isExactSpecSelected(refreshed, dimension, target)) {
exactSpecSelectionProof(refreshed, dimension, target)?.let { proof ->
selectionProofs[dimension] = proof
}
return null
}
pause(SPEC_SELECTION_POLL_MILLIS)
}
val lookup = locateExactSpec(input, dimension, target)
lookup.failure?.let { return it }
val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node))
if (outcome.result == FreshActionResult.AMBIGUOUS) {
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")
}
var verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
verification.failure?.let { return it }
if (verification.confirmed) return null
if (clickSucceeded) {
// Reacquire the exact option after PDD has had a chance to rerender.
// The fallback is still bounded to one center gesture and the same
// unique, server-provided exact value.
val refreshedLookup = locateExactSpec(input, dimension, target)
refreshedLookup.failure?.let { return it }
val gesture = driver.tapSpecFresh(requireNotNull(refreshedLookup.node))
if (gesture == FreshActionResult.AMBIGUOUS) {
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格手势目标不唯一")
}
if (gesture == FreshActionResult.SUCCESS) {
verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
verification.failure?.let { return it }
if (verification.confirmed) return null
return failure(SPEC_SELECTION_UNCONFIRMED, "无障碍点击和受控手势后未能确认精确选中状态")
}
if (outcome.result == FreshActionResult.SUCCESS) {
return failure(SPEC_SELECTION_UNCONFIRMED, "点击规格后未能确认精确选中状态")
}
val subreason = when (lastClickReason) {
val subreason = when (outcome.reason) {
FreshClickReason.ROOT_UNAVAILABLE -> SPEC_CLICK_ROOT_UNAVAILABLE
FreshClickReason.TARGET_NOT_FOUND -> SPEC_CLICK_TARGET_STALE
FreshClickReason.NO_CLICKABLE_ANCESTOR -> SPEC_CLICK_NO_CLICKABLE_ANCESTOR
@@ -433,6 +556,38 @@ class PurchaseRehearsalExecutor(
return failure(SPEC_CLICK_FAILED, subreason)
}
private data class ExactSelectionWait(
val confirmed: Boolean,
val failure: PurchaseExecutionOutcome? = null,
)
private fun waitForExactSelection(
input: PurchaseExecutionInput,
dimension: String,
target: String,
selectionProofs: MutableMap<String, ExactSpecSelectionProof>,
polls: Int,
): ExactSelectionWait {
repeat(polls) {
val refreshed = currentScreen(input)
refreshed.problem?.let { return ExactSelectionWait(false, failure(it.code, it.message)) }
if (rememberExactSelection(refreshed, dimension, target, selectionProofs)) return ExactSelectionWait(true)
pause(SPEC_SELECTION_POLL_MILLIS)
}
return ExactSelectionWait(false)
}
private fun rememberExactSelection(
screen: ParsedPddScreen,
dimension: String,
target: String,
selectionProofs: MutableMap<String, ExactSpecSelectionProof>,
): Boolean {
if (!isExactSpecSelected(screen, dimension, target)) return false
exactSpecSelectionProof(screen, dimension, target)?.let { proof -> selectionProofs[dimension] = proof }
return true
}
private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean {
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
if (candidates.any { it.text == target && (it.node.selected || it.node.checked) }) return true
@@ -453,7 +608,11 @@ class PurchaseRehearsalExecutor(
return matchingCandidates.size == 1 && matchingCandidates.single() == target
}
private data class ExactSpecSelectionProof(val dimension: String, val target: String)
private data class ExactSpecSelectionProof(
val dimension: String,
val target: String,
val panelType: SpecPanelType,
)
private fun exactSpecSelectionProof(
screen: ParsedPddScreen,
@@ -465,7 +624,7 @@ class PurchaseRehearsalExecutor(
val tokenCandidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { candidate ->
if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token
}
return ExactSpecSelectionProof(dimension, target).takeIf {
return ExactSpecSelectionProof(dimension, target, screen.specPanelType).takeIf {
tokenCandidates.size == 1 && tokenCandidates.single().text == target
}
}
@@ -476,6 +635,9 @@ class PurchaseRehearsalExecutor(
val summaryTokenMatched: Boolean,
val candidateCount: Int,
val proofPresent: Boolean,
val targetMatchCount: Int = 0,
val relocationAttempted: Boolean = false,
val relocationSwipes: Int = 0,
)
private fun verifyExactSpecSelection(
@@ -486,15 +648,21 @@ class PurchaseRehearsalExecutor(
): FinalSpecVerification {
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
val matchingNodes = candidates.filter { it.text == target }
val proofPresent = proof?.dimension == dimension && proof.target == target
val proofPresent = proof?.dimension == dimension && proof.target == target && proof.panelType == screen.specPanelType
if (matchingNodes.size == 1 && (matchingNodes.single().node.selected || matchingNodes.single().node.checked)) {
return FinalSpecVerification(true, "selected_node", false, candidates.size, proofPresent)
return FinalSpecVerification(true, "selected_node", false, candidates.size, proofPresent, matchingNodes.size)
}
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) {
return FinalSpecVerification(false, "visible_selected_conflict", false, candidates.size, proofPresent, matchingNodes.size)
}
val token = if (dimension == "size") SpecValueNormalizer.primarySizeToken(target) else target.trim()
val summaryTokenMatched = !token.isNullOrBlank() && SpecValueNormalizer.summaryHasExactToken(screen.selectedSummary, token)
if (!summaryTokenMatched) {
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofPresent)
if (screen.selectedSummary == null && screen.specPanelOpen && proofPresent) {
return FinalSpecVerification(true, "attempt_selection_state", false, candidates.size, true, matchingNodes.size)
}
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofPresent, matchingNodes.size)
}
// PDD can rerender or scroll the previously selected dimension out of
// the current viewport while the selected summary remains visible. A
@@ -505,18 +673,83 @@ class PurchaseRehearsalExecutor(
if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token
}
if (visibleTokenMatches.size == 1 && visibleTokenMatches.single().text == target) {
return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofPresent)
return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofPresent, matchingNodes.size)
}
if (visibleTokenMatches.isNotEmpty()) {
return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofPresent)
return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofPresent, matchingNodes.size)
}
return if (proofPresent) {
FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true)
FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true, matchingNodes.size)
} else {
FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, false)
FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, false, matchingNodes.size)
}
}
private fun verifyExactSpecSelectionWithRelocation(
input: PurchaseExecutionInput,
dimension: String,
target: String,
proof: ExactSpecSelectionProof?,
): FinalSpecVerification {
var screen = currentScreen(input)
screen.problem?.let {
return FinalSpecVerification(false, it.code, false, 0, proof != null)
}
var verification = verifyExactSpecSelection(screen, dimension, target, proof)
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
if (!screen.specPanelOpen) return verification.copy(reason = "relocation_panel_closed", relocationAttempted = true)
var signature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
}
var swipes = 0
for ((direction, limit) in listOf(SwipeDirection.DOWN to 3, SwipeDirection.UP to 6)) {
for (attempt in 0 until limit) {
val container = screen.specPanelContainer
?: return verification.copy(
reason = "relocation_container_missing",
relocationAttempted = true,
relocationSwipes = swipes,
)
if (!driver.swipePurchaseIn(container, direction, 350)) {
return verification.copy(
reason = "relocation_swipe_failed",
relocationAttempted = true,
relocationSwipes = swipes,
)
}
swipes++
pause(300)
screen = currentScreen(input)
screen.problem?.let {
return verification.copy(
reason = it.code,
relocationAttempted = true,
relocationSwipes = swipes,
)
}
if (!screen.specPanelOpen) {
return verification.copy(
reason = "relocation_panel_closed",
relocationAttempted = true,
relocationSwipes = swipes,
)
}
verification = verifyExactSpecSelection(screen, dimension, target, proof).copy(
relocationAttempted = true,
relocationSwipes = swipes,
)
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
val refreshedSignature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
}
if (refreshedSignature == signature) break
signature = refreshedSignature
}
}
return verification.copy(relocationAttempted = true, relocationSwipes = swipes)
}
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
/**
@@ -527,38 +760,40 @@ class PurchaseRehearsalExecutor(
* that direction early.
*/
private fun locateExactSpec(input: PurchaseExecutionInput, dimension: String, target: String): SpecLookup {
fun inspect(): Pair<SpecLookup?, String> {
data class Inspection(val lookup: SpecLookup?, val signature: String, val container: SnapshotNode?)
fun inspect(): Inspection {
val screen = currentScreen(input)
screen.problem?.let { return SpecLookup(failure = failure(it.code, it.message)) to "" }
screen.problem?.let { return Inspection(SpecLookup(failure = failure(it.code, it.message)), "", null) }
if (!screen.specPanelOpen) {
return SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")) to ""
return Inspection(SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")), "", null)
}
val dimensionValues = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
val exact = dimensionValues.filter { it.text == target }
if (exact.size > 1) {
return SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")) to ""
return Inspection(SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")), "", screen.specPanelContainer)
}
if (exact.size == 1 && exact.single().available) return SpecLookup(node = exact.single().node) to ""
if (exact.size == 1 && exact.single().available) return Inspection(SpecLookup(node = exact.single().node), "", screen.specPanelContainer)
if (exact.size == 1) {
return SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")) to ""
return Inspection(SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")), "", screen.specPanelContainer)
}
val signature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
}
return null to signature
return Inspection(null, signature, screen.specPanelContainer)
}
var inspected = inspect()
inspected.first?.let { return it }
var currentSignature = inspected.second
inspected.lookup?.let { return it }
var currentSignature = inspected.signature
for ((direction, limit) in listOf(SwipeDirection.DOWN to 3, SwipeDirection.UP to 6)) {
for (attempt in 0 until limit) {
if (!driver.swipePurchase(direction, 350)) break
val container = inspected.container ?: break
if (!driver.swipePurchaseIn(container, direction, 350)) break
pause(300)
inspected = inspect()
inspected.first?.let { return it }
if (inspected.second == currentSignature) break
currentSignature = inspected.second
inspected.lookup?.let { return it }
if (inspected.signature == currentSignature) break
currentSignature = inspected.signature
}
}
return SpecLookup(failure = failure(SPEC_TARGET_NOT_VISIBLE, "有界搜索后未找到精确规格"))
@@ -602,23 +837,26 @@ class PurchaseRehearsalExecutor(
observedPrice: Long?,
selectionProofs: Map<String, ExactSpecSelectionProof>,
): PurchaseExecutionOutcome? {
val screen = currentScreen(input)
screen.problem?.let { return failure(it.code, it.message) }
val selected = listOf("color" to input.mappedColor, "size" to input.mappedSize).filter { it.second.isNotBlank() }.map { (dimension, rawTarget) ->
dimension to (normalizedTarget(dimension, rawTarget)
?: return failure(SPEC_SAFE_TARGET_MISSING, "下发规格无法安全规范化"))
}
selected.forEach { (dimension, target) ->
val verification = verifyExactSpecSelection(screen, dimension, target, selectionProofs[dimension])
val verification = verifyExactSpecSelectionWithRelocation(input, dimension, target, selectionProofs[dimension])
if (!verification.confirmed) {
val screen = currentScreen(input)
val panel = screen.specPanelType.name.lowercase()
val diagnostic = "dimension=$dimension,reason=${verification.reason},panel=$panel," +
"summary=${screen.selectedSummary != null},tokenMatched=${verification.summaryTokenMatched}," +
"candidates=${verification.candidateCount},proof=${verification.proofPresent}"
"candidates=${verification.candidateCount},targetMatches=${verification.targetMatchCount}," +
"proof=${verification.proofPresent}," +
"relocated=${verification.relocationAttempted},relocationSwipes=${verification.relocationSwipes}"
return failure(SPEC_SELECTION_UNCONFIRMED, "最终规格复核未能确认精确选中状态 [$diagnostic]")
}
}
if (readQuantity() != input.quantity) return failure("PURCHASE_QUANTITY_MISMATCH", "最终数量复核失败")
val screen = currentScreen(input)
screen.problem?.let { return failure(it.code, it.message) }
val price = screen.priceCent ?: observedPrice ?: return failure("RULE_NOT_MATCHED", "最终价格复核失败")
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) {
return failure("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
@@ -685,12 +923,14 @@ class PurchaseRehearsalExecutor(
companion object {
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
private const val OPEN_PRODUCT_POLL_LIMIT = 50
private const val OPEN_PRODUCT_POLL_LIMIT = 150
private const val PRODUCT_PAGE_POLL_LIMIT = 150
private const val PRODUCT_PAGE_STABLE_READS = 2
private const val OPEN_PRODUCT_RETRY_POLLS = 10
private const val OPEN_PRODUCT_POLL_MILLIS = 100L
private const val SPEC_ENTRY_READY_WAIT_POLLS = 20
private const val SPEC_ENTRY_READY_POLL_MILLIS = 100L
private const val SPEC_SELECTION_CLICK_ATTEMPTS = 3
private const val SPEC_POST_CLICK_VERIFY_POLLS = 30
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
@@ -699,7 +939,8 @@ class PurchaseRehearsalExecutor(
private const val SPEC_ENTRY_TARGET_AMBIGUOUS = "PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS"
private const val SPEC_ENTRY_CLICK_FAILED = "PURCHASE_SPEC_ENTRY_CLICK_FAILED"
private const val SPEC_ENTRY_OPENED_REVIEW = "PURCHASE_SPEC_ENTRY_OPENED_REVIEW"
private const val SPEC_PANEL_NOT_OPENED = "PURCHASE_SPEC_PANEL_NOT_OPENED"
private const val SPEC_ENTRY_CLICK_NO_EFFECT = "PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT"
private const val SPEC_PANEL_EVIDENCE_NOT_MATCHED = "PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED"
private const val SPEC_TARGET_NOT_VISIBLE = "PURCHASE_SPEC_TARGET_NOT_VISIBLE"
private const val SPEC_TARGET_AMBIGUOUS = "PURCHASE_SPEC_TARGET_AMBIGUOUS"
private const val SPEC_SAFE_TARGET_MISSING = "PURCHASE_SPEC_SAFE_TARGET_MISSING"
@@ -57,6 +57,7 @@ import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.Executors
@@ -255,13 +256,20 @@ class AgentForegroundService : Service() {
flushPurchaseOutbox(api, token)
val collectionCooldown = activeCollectionCooldown()
val purchaseTask = api.nextPurchaseTask(token)
when (TaskDispatchPolicy.decide(purchaseTask != null, collectionCooldown != null)) {
when (TaskDispatchPolicy.decide(purchaseTask?.status, collectionCooldown != null)) {
TaskDispatchDecision.RUN_PURCHASE -> {
cancelIdleReturn("收到新的采购任务")
releaseCollectionCooldownWakeLock()
schedulePurchaseTask(api, requireNotNull(purchaseTask), token)
return MANUAL_PURCHASE_TASK
}
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH -> {
val waitingTask = requireNotNull(purchaseTask)
cancelIdleReturn("等待采购规格匹配")
stateStore.update("ONLINE", "采购任务 #${waitingTask.taskId} 正在匹配规格", tokenStored = true)
updateNotification("采购任务 #${waitingTask.taskId} 等待规格匹配")
return MANUAL_PURCHASE_MATCH_PENDING
}
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN -> {
val ticket = requireNotNull(collectionCooldown)
showCollectionCooldown(ticket)
@@ -434,8 +442,10 @@ class AgentForegroundService : Service() {
}
private fun executePurchaseTask(api: AgentApiClient, initial: PurchaseAgentTask, token: String) {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
var resultSafelyStored = false
val lastStep = AtomicReference("started")
try {
val claimed = if (initial.status == "pending") {
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
@@ -472,7 +482,10 @@ class AgentForegroundService : Service() {
driver = accessibility,
openLink = { PddLinkLauncher(this).open(it) },
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) },
stepChanged = { step -> purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) },
stepChanged = { step ->
lastStep.set(step)
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
},
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
beforeOrderSubmit = { evidence ->
val boundaryRequestId = UUID.randomUUID().toString()
@@ -513,6 +526,14 @@ class AgentForegroundService : Service() {
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
resultSafelyStored = true
PurchaseResultBubblePolicy.create(
taskId = task.taskId,
resultType = outcome.resultType,
lastStep = lastStep.get(),
resultMessage = outcome.message,
)?.let { presentation ->
GoAutoAccessibilityService.instance?.showPurchaseResultBubble(presentation)
}
beginIdleReturnCooldown()
flushPurchaseOutbox(api, token)
val message = if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message
@@ -611,6 +632,7 @@ class AgentForegroundService : Service() {
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String,
): TaskExecutionSummary {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
return try {
executeTaskWhileAwake(api, initialTask, token)
@@ -1059,6 +1081,7 @@ class AgentForegroundService : Service() {
const val MANUAL_EMPTY = "empty"
const val MANUAL_COLLECTION_TASK = "collection_task"
const val MANUAL_PURCHASE_TASK = "purchase_task"
const val MANUAL_PURCHASE_MATCH_PENDING = "purchase_match_pending"
const val MANUAL_BUSY = "busy"
const val MANUAL_CONFIG_REQUIRED = "config_required"
const val MANUAL_AUTH_ERROR = "auth_error"
@@ -99,13 +99,15 @@ internal object CollectionCooldownPolicy {
internal enum class TaskDispatchDecision {
RUN_PURCHASE,
WAIT_FOR_PURCHASE_MATCH,
WAIT_FOR_COLLECTION_COOLDOWN,
CHECK_COLLECTION,
}
internal object TaskDispatchPolicy {
fun decide(purchaseAvailable: Boolean, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
purchaseAvailable -> TaskDispatchDecision.RUN_PURCHASE
fun decide(purchaseStatus: String?, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
purchaseStatus == "spec_probe_pending" -> TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH
purchaseStatus != null -> TaskDispatchDecision.RUN_PURCHASE
collectionCooldownActive -> TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN
else -> TaskDispatchDecision.CHECK_COLLECTION
}
@@ -0,0 +1,114 @@
package cn.ilapage.goauto.agent.ui
import android.content.Context
import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.TextView
import cn.ilapage.goauto.agent.R
class PurchaseResultBubbleController(
private val context: Context,
private val canShow: () -> Boolean,
) {
private val mainHandler = Handler(Looper.getMainLooper())
private val windowManager = context.getSystemService(WindowManager::class.java)
private val session = PurchaseResultBubbleSession()
private var bubbleView: View? = null
fun show(presentation: PurchaseResultBubblePresentation) {
val revision = session.replace()
mainHandler.post {
if (!session.isCurrent(revision)) return@post
removeCurrentView()
if (!canShow()) return@post
val view = buildView(presentation)
runCatching { windowManager.addView(view, layoutParams()) }
.onSuccess {
bubbleView = view
mainHandler.postDelayed({
if (session.isCurrent(revision)) {
session.dismiss()
removeCurrentView()
}
}, presentation.durationMillis)
}
}
}
fun dismiss() {
session.dismiss()
mainHandler.post(::removeCurrentView)
}
private fun buildView(presentation: PurchaseResultBubblePresentation): View {
val horizontalPadding = context.dp(16)
val verticalPadding = context.dp(12)
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding)
elevation = context.dp(8).toFloat()
background = GradientDrawable().apply {
cornerRadius = context.dp(16).toFloat()
setColor(context.getColor(if (presentation.isFailure) R.color.purchase_result_failure_background else R.color.purchase_result_success_background))
}
contentDescription = "${presentation.title}。${presentation.message}"
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
addView(TextView(context).apply {
text = presentation.title
setTextColor(context.getColor(R.color.purchase_result_text))
textSize = 14f
setTypeface(typeface, android.graphics.Typeface.BOLD)
maxLines = 2
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
addView(TextView(context).apply {
text = presentation.message
setTextColor(context.getColor(R.color.purchase_result_text_secondary))
textSize = 13f
maxLines = 3
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
topMargin = context.dp(4)
})
}
}
private fun layoutParams(): WindowManager.LayoutParams {
val availableWidth = context.resources.displayMetrics.widthPixels - context.dp(32)
return WindowManager.LayoutParams(
availableWidth.coerceAtMost(context.dp(360)),
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT,
).apply {
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
y = context.statusBarHeight() + context.dp(8)
windowAnimations = android.R.style.Animation_Toast
}
}
private fun removeCurrentView() {
val view = bubbleView ?: return
bubbleView = null
runCatching { windowManager.removeViewImmediate(view) }
}
private fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
@Suppress("DiscouragedApi")
private fun Context.statusBarHeight(): Int {
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else dp(24)
}
}
@@ -0,0 +1,86 @@
package cn.ilapage.goauto.agent.ui
data class PurchaseResultBubblePresentation(
val title: String,
val message: String,
val isFailure: Boolean,
val durationMillis: Long,
)
object PurchaseResultBubblePolicy {
const val SUCCESS_DURATION_MILLIS = 3_000L
const val FAILURE_DURATION_MILLIS = 8_000L
private const val MAX_MESSAGE_LENGTH = 96
private val stepLabels = mapOf(
"started" to "准备采购",
"openProduct" to "打开商品",
"verifyProduct" to "核对商品",
"openSpecPanel" to "打开规格面板",
"selectSpec" to "选择颜色与尺码",
"setQuantity" to "设置数量",
"verifyUnitPrice" to "核对价格",
"verifyOrderSummary" to "核对订单",
"probeSpecs" to "采集规格",
"updateShippingAddress" to "更新收货地址",
"createOrder" to "创建待付款订单",
"order_submit_started" to "创建待付款订单",
"readOrderResult" to "读取订单编号和下单时间",
)
fun create(
taskId: Long,
resultType: String,
lastStep: String?,
resultMessage: String?,
): PurchaseResultBubblePresentation? {
if (resultType == "spec_probe_completed") return null
val failure = resultType == "failed" || resultType == "order_result_unknown"
if (failure) {
val label = stepLabels[lastStep] ?: "处理采购任务"
return PurchaseResultBubblePresentation(
title = if (resultType == "order_result_unknown") "CG-$taskId 待人工核对|$label" else "CG-$taskId 失败于:$label",
message = sanitize(resultMessage, "采购失败,请返回 Agent 查看详情"),
isFailure = true,
durationMillis = FAILURE_DURATION_MILLIS,
)
}
val successMessage = when (resultType) {
"order_created" -> "已获取订单编号和下单时间"
"rehearsal_completed" -> "商品、规格、数量和价格复核完成"
else -> sanitize(resultMessage, "采购结果已安全保存")
}
return PurchaseResultBubblePresentation(
title = "CG-$taskId 采购完成",
message = successMessage,
isFailure = false,
durationMillis = SUCCESS_DURATION_MILLIS,
)
}
private fun sanitize(value: String?, fallback: String): String {
val normalized = value.orEmpty()
.replace(Regex("https?://\\S+", RegexOption.IGNORE_CASE), "[链接已隐藏]")
.replace(Regex("(?i)(token|authorization|cookie)\\s*[:=]\\s*\\S+")) { match ->
"${match.groupValues[1]}=[已隐藏]"
}
.replace(Regex("[\\r\\n\\t]+"), " ")
.replace(Regex("\\s{2,}"), " ")
.trim()
.ifBlank { fallback }
return normalized.take(MAX_MESSAGE_LENGTH)
}
}
class PurchaseResultBubbleSession {
private var revision = 0L
@Synchronized
fun replace(): Long = ++revision
@Synchronized
fun dismiss(): Long = ++revision
@Synchronized
fun isCurrent(candidate: Long): Boolean = candidate == revision
}
@@ -36,8 +36,6 @@ import cn.ilapage.goauto.agent.network.HistoryPage
import cn.ilapage.goauto.agent.network.HistoryColorImage
import cn.ilapage.goauto.agent.network.PurchaseHistoryDetail
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
import cn.ilapage.goauto.agent.network.PurchaseResetResult
import cn.ilapage.goauto.agent.network.PurchaseRetryResult
import cn.ilapage.goauto.agent.persistence.TaskHistoryCache
import cn.ilapage.goauto.agent.service.AgentForegroundService
import cn.ilapage.goauto.agent.service.AgentSettingsStore
@@ -85,10 +83,12 @@ internal object CollectionResetPolicy {
internal object PurchaseRetryPolicy {
fun showsAction(status: String, retryable: Boolean): Boolean = status == "failed" && retryable
fun usesInPlaceReset(continuing: Boolean): Boolean = !continuing
fun confirmationMessage(continuing: Boolean = false): String = if (continuing) {
"替代商品已完成匹配。系统会保留原任务并创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
} else {
"将使用服务端最新采购规则重跑当前任务,任务号和商品、规格、价格快照不变;可能创建拼多多待付款订单,但不会支付。"
"系统会复用原采购任务并开始新一次执行,使用当前有效采购规则,商品和规格等任务快照保持不变;可能创建拼多多待付款订单,但不会支付。"
}
}
@@ -748,7 +748,7 @@ class TaskHistoryFragment : Fragment() {
contentDescription = "重试采购任务 CG-${task.taskId}"
setOnClickListener { confirmPurchaseRetry(task) }
}, collectionCardParams())
resultColumn.addView(context.centeredMessage("重试边界", "复用当前任务并刷新采购规则;不会执行支付。"))
resultColumn.addView(context.centeredMessage("重试边界", "复用当前任务并新增一次执行;使用当前有效采购规则,商品和规格快照不变,不会执行支付。"))
} else if (!replacementInProgress && task.status == "failed") {
val reason = task.retryDisabledReason?.takeIf(String::isNotBlank) ?: "请在管理端核对任务状态。"
resultColumn.addView(context.centeredMessage("不可重试", reason))
@@ -866,22 +866,25 @@ class TaskHistoryFragment : Fragment() {
val generation = ++requestGeneration
showLoading(if (continuing) "正在提交继续采购请求…" else "正在提交重试请求…")
Thread {
runCatching {
runCatching<Unit> {
val client = AgentApiClient(serverUrl)
val requestId = UUID.randomUUID().toString()
if (continuing) client.retryPurchaseTask(taskId, requestId, credentials.token)
else client.resetPurchaseTask(taskId, requestId, credentials.token)
}
.onSuccess { result ->
if (PurchaseRetryPolicy.usesInPlaceReset(continuing)) {
val result = client.resetPurchaseTask(taskId, requestId, credentials.token)
resultColumn.post {
if (!isAdded || generation != requestGeneration) return@post
AgentForegroundService.start(requireContext())
when (result) {
is PurchaseRetryResult -> showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
is PurchaseResetResult -> showPurchaseResetSuccess(result.taskNo, result.taskId, result.attemptNumber)
}
showPurchaseResetSuccess(result.taskNo, result.taskId, result.attemptNumber)
}
} else {
val result = client.retryPurchaseTask(taskId, requestId, credentials.token)
resultColumn.post {
if (!isAdded || generation != requestGeneration) return@post
AgentForegroundService.start(requireContext())
showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
}
}
}
.onFailure { error ->
resultColumn.post {
if (isAdded && generation == requestGeneration) {
@@ -892,14 +895,14 @@ class TaskHistoryFragment : Fragment() {
}.start()
}
private fun showPurchaseRetrySuccess(sourceTaskNo: String, taskNo: String, taskId: Long) {
private fun showPurchaseResetSuccess(taskNo: String, taskId: Long, attemptNumber: Int) {
resultColumn.removeAllViews()
resultColumn.addView(requireContext().centeredMessage(
"已创建新任务 $taskNo",
"旧任务 $sourceTaskNo 已保留。新任务将由当前设备按正常队列执行;系统不会支付。",
"$taskNo 已进入第 $attemptNumber 次执行",
"原采购任务已复用,并将由当前设备按正常队列执行;系统不会支付。",
))
resultColumn.addView(MaterialButton(requireContext()).apply {
text = "查看新任务"
text = "查看当前任务"
minimumHeight = requireContext().dp(48)
setOnClickListener { loadPurchaseDetail(taskId) }
}, collectionCardParams())
@@ -910,14 +913,14 @@ class TaskHistoryFragment : Fragment() {
}, collectionCardParams())
}
private fun showPurchaseResetSuccess(taskNo: String, taskId: Long, attemptNumber: Int) {
private fun showPurchaseRetrySuccess(sourceTaskNo: String, taskNo: String, taskId: Long) {
resultColumn.removeAllViews()
resultColumn.addView(requireContext().centeredMessage(
"已加入重试队列 $taskNo",
"任务号保持不变,将使用最新采购规则进行第 $attemptNumber 次尝试;系统不会支付。",
"已创建新任务 $taskNo",
"旧任务 $sourceTaskNo 已保留。新任务将由当前设备按正常队列执行;系统不会支付。",
))
resultColumn.addView(MaterialButton(requireContext()).apply {
text = "查看当前任务"
text = "查看新任务"
minimumHeight = requireContext().dp(48)
setOnClickListener { loadPurchaseDetail(taskId) }
}, collectionCardParams())
@@ -8,4 +8,8 @@
<color name="agent_text_muted">#CBD5E1</color>
<color name="agent_warning">#FBBF24</color>
<color name="agent_error">#F87171</color>
<color name="purchase_result_success_background">#166534</color>
<color name="purchase_result_failure_background">#991B1B</color>
<color name="purchase_result_text">#FFFFFF</color>
<color name="purchase_result_text_secondary">#F1F5F9</color>
</resources>
@@ -86,15 +86,19 @@ class CollectionCooldownPolicyTest {
fun `purchase keeps priority while collection waits for cooldown`() {
assertEquals(
TaskDispatchDecision.RUN_PURCHASE,
TaskDispatchPolicy.decide(purchaseAvailable = true, collectionCooldownActive = true),
TaskDispatchPolicy.decide(purchaseStatus = "pending", collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH,
TaskDispatchPolicy.decide(purchaseStatus = "spec_probe_pending", collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN,
TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = true),
TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.CHECK_COLLECTION,
TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = false),
TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = false),
)
}
}
@@ -16,6 +16,46 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseLiveAutomationTest {
@Test
fun `verified spec panel advances through one exact confirm target`() {
val driver = SpecConfirmationDriver()
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation()
assertEquals(listOf("确定"), driver.clicked)
assertEquals("order", driver.page)
}
@Test
fun `ambiguous missing and unchanged spec confirmation stop safely`() {
val ambiguous = SpecConfirmationDriver(confirmLabels = listOf("确定", "确认"))
val ambiguousError = runCatching { PurchaseLiveAutomation(ambiguous, pause = {}).advanceToOrderConfirmation() }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", ambiguousError.code)
assertTrue(ambiguous.clicked.isEmpty())
val missing = SpecConfirmationDriver(confirmLabels = emptyList(), specPanelScrollable = true)
val missingError = runCatching { PurchaseLiveAutomation(missing, pause = {}).advanceToOrderConfirmation() }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRM_TARGET_MISSING", missingError.code)
assertTrue(missing.clicked.isEmpty())
val unchanged = SpecConfirmationDriver(advanceAfterClick = false)
val unchangedError = runCatching { PurchaseLiveAutomation(unchanged, pause = {}).advanceToOrderConfirmation() }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRMATION_UNCONFIRMED", unchangedError.code)
assertEquals(listOf("确定"), unchanged.clicked)
}
@Test
fun `existing order confirmation does not click submit or payment controls`() {
val driver = SpecConfirmationDriver(startOnOrderPage = true)
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation()
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `address is retagged verified and final order button can only be clicked once`() {
val driver = LiveDriver()
@@ -413,6 +453,68 @@ class PurchaseLiveAutomationTest {
addressSuffix = "_cg11",
)
private class SpecConfirmationDriver(
private val confirmLabels: List<String> = listOf("确定"),
private val advanceAfterClick: Boolean = true,
private val specPanelScrollable: Boolean = false,
startOnOrderPage: Boolean = false,
) : PurchaseUiDriver {
var page = if (startOnOrderPage) "order" else "spec"
val clicked = mutableListOf<String>()
override fun capture(): UiSnapshot = if (page == "order") {
snapshot(listOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
node("scroll", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
node("summary", "已选 黑色 均码"),
node("quantity", "1", className = "android.widget.EditText"),
node("phone", "138****5678"),
node("submit", "提交订单", clickable = true),
node("payment", "微信支付"),
))
} else {
snapshot(buildList {
add(node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)))
if (specPanelScrollable) {
add(node("scroll", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 1500)))
}
add(node("title", "确认款式"))
add(node("summary", "已选 黑色 均码"))
val parent = "scroll".takeIf { specPanelScrollable }
val prefix = if (specPanelScrollable) "scroll/" else ""
add(node("${prefix}color-heading", "颜色分类", parentPath = parent))
add(node("${prefix}color", "黑色", clickable = true, parentPath = parent))
add(node("${prefix}size-heading", "尺码", parentPath = parent))
add(node("${prefix}size", "均码", clickable = true, parentPath = parent))
add(node("quantity", "1", className = "android.widget.EditText"))
confirmLabels.forEachIndexed { index, label -> add(node("confirm-$index", label, clickable = true)) }
})
}
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicked += target.label
if (advanceAfterClick && target.label in confirmLabels) page = "order"
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
private fun snapshot(nodes: List<SnapshotNode>) = UiSnapshot(PDD, ACTIVITY, nodes)
private fun node(
path: String,
text: String,
clickable: Boolean = false,
scrollable: Boolean = false,
className: String = "android.widget.TextView",
bounds: NodeBounds = NodeBounds(20, 100, 900, 180),
parentPath: String? = null,
) = SnapshotNode(path, parentPath, text, null, null, className, bounds, clickable, scrollable, false, false, true, true)
}
private class LiveDriver(
private val addressClipped: Boolean = false,
private val duplicatePanels: Boolean = false,
@@ -8,6 +8,7 @@ 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.PurchaseSpecGesturePolicy
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
import cn.ilapage.goauto.agent.automation.RuleValidationException
import cn.ilapage.goauto.agent.automation.SnapshotNode
@@ -21,6 +22,22 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseRehearsalExecutorTest {
@Test
fun `spec gesture policy excludes irreversible and out of bounds targets`() {
fun target(label: String, bounds: NodeBounds = NodeBounds(20, 100, 300, 180)) = SnapshotNode(
"target", null, label, null, null, "android.widget.TextView", bounds,
clickable = true, scrollable = false, selected = false, checked = false, enabled = true, visible = true,
)
assertTrue(PurchaseSpecGesturePolicy.isAllowed(target("选择规格"), 1080, 2200))
assertTrue(PurchaseSpecGesturePolicy.isAllowed(target("XL"), 1080, 2200))
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("提交订单"), 1080, 2200))
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("确认购买"), 1080, 2200))
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("立即支付"), 1080, 2200))
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("修改地址"), 1080, 2200))
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("XL", NodeBounds(-20, 100, -2, 180)), 1080, 2200))
}
@Test
fun `shared current live rule matches Android contract`() {
val raw = requireNotNull(javaClass.classLoader?.getResource("purchase-rule-live-v1.json")) {
@@ -64,12 +81,12 @@ class PurchaseRehearsalExecutorTest {
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(2_000L, outcome.actualUnitPriceCent)
assertEquals(1, openCount)
assertEquals(0, openCount)
assertEquals(2, driver.swipeCount)
assertEquals(2L, driver.quantity)
assertTrue(driver.clicked.containsAll(listOf("打开", "选择规格", "黑色", "XL")))
assertTrue(driver.clicked.containsAll(listOf("选择规格", "黑色", "XL")))
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
assertTrue(pauses.contains(700))
assertFalse(pauses.contains(700))
}
@Test
@@ -130,6 +147,8 @@ class PurchaseRehearsalExecutorTest {
assertEquals("rehearsal_completed", outcome.resultType)
assertTrue(driver.clicked.contains("XL"))
assertEquals(3, driver.upSwipeCount)
assertTrue(driver.swipeInPaths.isNotEmpty())
assertTrue(driver.swipeInPaths.all { it == "scroll" })
}
@Test
@@ -147,18 +166,31 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `rerendered exact size is refound and selected on the second attempt`() {
fun `rerendered exact size falls back to one verified center gesture`() {
val driver = FakePurchaseDriver(
sizeClickResults = mutableListOf(FreshActionResult.NOT_FOUND, FreshActionResult.SUCCESS),
sizeClickResults = mutableListOf(FreshActionResult.NOT_FOUND),
specTapResult = FreshActionResult.SUCCESS,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(2, driver.sizeClickCount)
assertEquals(1, driver.sizeClickCount)
assertEquals(1, driver.specTapCount)
assertEquals("XL", driver.size)
}
@Test
fun `already selected exact size is not clicked again`() {
val driver = FakePurchaseDriver(initialSize = "XL")
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(0, driver.sizeClickCount)
assertEquals(0, driver.specTapCount)
}
@Test
fun `long exact size can be verified by one unambiguous short summary token`() {
val target = "XL【建议121-140斤】"
@@ -201,7 +233,7 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `final verification rejects prior proof when selected summary is no longer visible`() {
fun `final verification keeps exact attempt selection when summary is no longer visible`() {
val driver = FakePurchaseDriver(
hideColorAfterQuantitySet = true,
hideSelectedSummaryAfterQuantitySet = true,
@@ -209,11 +241,42 @@ class PurchaseRehearsalExecutorTest {
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, driver.clicked.count { it == "黑色" })
}
@Test
fun `final verification reuses exact attempt state without scrolling back`() {
val driver = FakePurchaseDriver(
hideColorAfterQuantitySet = true,
hideSelectedSummaryAfterQuantitySet = true,
restoreHiddenColorOnDownSwipe = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertFalse(driver.swipeInPaths.contains("scroll"))
assertEquals(1, driver.clicked.count { it == "黑色" })
assertEquals(1, driver.clicked.count { it == "XL" })
}
@Test
fun `final verification rejects explicit conflicting selected color`() {
val driver = FakePurchaseDriver(
colors = listOf("黑色", "白色"),
hideSelectedSummaryAfterQuantitySet = true,
selectedColorOverrideAfterQuantitySet = "白色",
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
assertTrue(outcome.message.contains("dimension=color"))
assertTrue(outcome.message.contains("reason=summary_token_missing"))
assertTrue(outcome.message.contains("proof=true"))
assertTrue(outcome.message.contains("reason=visible_selected_conflict"))
assertTrue(outcome.message.contains("targetMatches=1"))
assertEquals(1, driver.clicked.count { it == "黑色" })
}
@Test
@@ -280,16 +343,17 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `persistent exact size click failure stops after three attempts`() {
fun `persistent exact size click failure stops after one action and one bounded gesture`() {
val driver = FakePurchaseDriver(
sizeClickResults = MutableList(3) { FreshActionResult.FAILED },
sizeClickResults = mutableListOf(FreshActionResult.FAILED),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_CLICK_FAILED", outcome.errorCode)
assertEquals("action_click_false", outcome.message)
assertEquals(3, driver.sizeClickCount)
assertEquals(1, driver.sizeClickCount)
assertEquals(1, driver.specTapCount)
assertEquals(null, driver.size)
}
@@ -371,8 +435,8 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `ambiguous browser target stops safely`() {
val driver = FakePurchaseDriver(duplicateOpen = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_AMBIGUOUS", outcome.errorCode)
assertTrue(driver.clicked.isEmpty())
}
@@ -383,10 +447,10 @@ class PurchaseRehearsalExecutorTest {
openClickResults = mutableListOf(FreshActionResult.FAILED),
openPddOnFailedClick = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals(1, driver.openClickCount)
}
@@ -396,10 +460,10 @@ class PurchaseRehearsalExecutorTest {
openClickResults = mutableListOf(FreshActionResult.NOT_FOUND, FreshActionResult.SUCCESS),
)
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals(2, driver.openClickCount)
assertTrue(pauses.size <= 50)
}
@@ -407,15 +471,50 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `persistent open click failure is bounded and remains safely failed`() {
val driver = FakePurchaseDriver(
openClickResults = MutableList(10) { FreshActionResult.FAILED },
openClickResults = MutableList(20) { FreshActionResult.FAILED },
)
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertTrue(driver.openClickCount in 1..5)
assertEquals(50, pauses.size)
assertTrue(driver.openClickCount in 1..15)
assertEquals(150, pauses.size)
}
@Test
fun `five second browser interstitial can still reach a stable product page`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(browserOpenVisibleAfterCaptures = 50)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals(1, driver.openClickCount)
assertTrue(pauses.count { it == 100L } in 52..60)
}
@Test
fun `pdd foreground without stable product evidence times out at fifteen seconds`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(loadingPddCaptures = 200)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertEquals("打开拼多多后未识别到稳定商品页面", outcome.message)
assertEquals(150, pauses.count { it == 100L })
}
@Test
fun `explicit pdd login page fails before the product timeout`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(pddProblemLabels = listOf("手机号登录", "登录后继续"))
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_LOGIN_REQUIRED", outcome.errorCode)
assertTrue(pauses.count { it == 100L } < 5)
}
@Test
@@ -434,14 +533,58 @@ class PurchaseRehearsalExecutorTest {
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_NOT_OPENED", outcome.errorCode)
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]",
outcome.message,
)
assertEquals(0, driver.specTapCount)
assertFalse(outcome.message.orEmpty().contains("确认款式"))
}
@Test
fun `unchanged spec entry action uses one verified center gesture`() {
val driver = FakePurchaseDriver(
entryActionHasEffect = false,
specTapResult = FreshActionResult.SUCCESS,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, driver.specTapCount)
assertTrue(driver.panel)
}
@Test
fun `unchanged spec entry after action and gesture returns no effect`() {
val driver = FakePurchaseDriver(
entryActionHasEffect = false,
specTapResult = FreshActionResult.SUCCESS,
specTapHasEffect = false,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
assertEquals(1, driver.specTapCount)
assertFalse(driver.panel)
}
@Test
fun `failed spec entry gesture preserves the action click no effect reason`() {
val driver = FakePurchaseDriver(
entryActionHasEffect = false,
specTapResult = FreshActionResult.FAILED,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_FAILED", outcome.errorCode)
assertEquals("gesture_failed_after_action_click_no_effect", outcome.message)
assertFalse(outcome.message.contains("unknown"))
}
@Test
fun `missing spec entry emits scalar source counts without node text`() {
val diagnostics = mutableListOf<String>()
@@ -460,8 +603,6 @@ class PurchaseRehearsalExecutorTest {
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
diagnostics.single(),
)
// One 100ms pause belongs to the existing open-product foreground poll;
// the diagnostic proves the entry-ready loop itself used exactly 20.
assertEquals(21, pauses.count { it == 100L })
assertFalse(diagnostics.single().contains("选择规格"))
}
@@ -488,7 +629,7 @@ class PurchaseRehearsalExecutorTest {
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, pauses.count { it == 100L })
assertEquals(2, pauses.count { it == 100L })
}
@Test
@@ -555,11 +696,22 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `live rule spec probe returns before address or order actions`() {
fun `live rule spec probe ignores historical mapping and returns before address or order actions`() {
val driver = FakePurchaseDriver()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
.execute(input().copy(executionMode = "live", phase = "spec_probe", addressSuffix = "_cg42"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
.execute(
input().copy(
executionMode = "live",
phase = "spec_probe",
mappedColor = "历史颜色",
mappedSize = "历史尺码",
addressSuffix = "_cg42",
),
PurchaseRuleParser.parse(liveRule()),
PurchaseAgentCapabilities.supported,
)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals("{\"dimensions\":[]}", outcome.probedSpecs)
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("地址") || it.contains("支付") })
}
@@ -808,6 +960,8 @@ class PurchaseRehearsalExecutorTest {
private val missingSpecEntry: Boolean = false,
private val specEntryVisibleAfterPddCaptures: Int = 0,
private val loadingPddCaptures: Int = 0,
private val browserOpenVisibleAfterCaptures: Int = 0,
private val pddProblemLabels: List<String> = emptyList(),
private val includeReviewEntry: Boolean = false,
private val openReviewOnBottomClick: Boolean = false,
private val reviewBackSucceeds: Boolean = true,
@@ -817,12 +971,18 @@ class PurchaseRehearsalExecutorTest {
private val sizeClickResults: MutableList<FreshActionResult> = mutableListOf(),
private val forcedSizeClickReason: FreshClickReason? = null,
private val forcedEntryClickReason: FreshClickReason? = null,
private val initialSize: String? = null,
private val entryActionHasEffect: Boolean = true,
private val specTapResult: FreshActionResult = FreshActionResult.FAILED,
private val specTapHasEffect: Boolean = true,
private val sizeSelectsOnFailedClick: Boolean = false,
private val selectedSizeSummaryOverride: String? = null,
private val hideSizeSelectedState: Boolean = false,
private val hideColorAfterQuantitySet: Boolean = false,
private val hideSizeAfterQuantitySet: Boolean = false,
private val hideSelectedSummaryAfterQuantitySet: Boolean = false,
private val restoreHiddenColorOnDownSwipe: Boolean = false,
private val selectedColorOverrideAfterQuantitySet: String? = null,
private val selectedSummaryOverrideAfterQuantitySet: String? = null,
private val finalSizesAfterQuantitySet: List<String>? = null,
soldOut: Boolean = false,
@@ -838,7 +998,7 @@ class PurchaseRehearsalExecutorTest {
var browser = false
var panel = false
var color: String? = null
var size: String? = null
var size: String? = initialSize
var quantity = 1L
var swipeCount = 0
var upSwipeCount = 0
@@ -846,16 +1006,24 @@ class PurchaseRehearsalExecutorTest {
var sizeClickCount = 0
var pullDownCount = 0
var backCount = 0
var specTapCount = 0
val swipeInPaths = mutableListOf<String>()
private var soldOut = soldOut
private var allSpecsUnavailable = allSpecsUnavailable
private var productEvidenceLost = false
private var reviewPage = false
private var pddCaptureCount = 0
private var browserCaptureCount = 0
private var hiddenColorRestored = false
val clicked = mutableListOf<String>()
val clickedPaths = mutableListOf<String>()
override fun capture(): UiSnapshot {
if (browser && !panel && color == null && size == null) {
browserCaptureCount++
if (browserCaptureCount <= browserOpenVisibleAfterCaptures) {
return UiSnapshot("com.heytap.browser", "BrowserActivity", listOf(node("content", "", 0, 0, 1080, 2200)))
}
val openNodes = mutableListOf(node("open", "打开", 0, 100, 300, 180, clickable = true))
if (duplicateOpen) openNodes += node("open2", "打开", 400, 100, 700, 180, clickable = true)
openNodes += node("content", "", 0, 0, 1080, 2200)
@@ -865,6 +1033,11 @@ class PurchaseRehearsalExecutorTest {
if (pddCaptureCount <= loadingPddCaptures) {
return UiSnapshot(PDD, ACTIVITY, listOf(node("content", "", 0, 0, 1080, 2200)))
}
if (pddProblemLabels.isNotEmpty()) {
return UiSnapshot(PDD, ACTIVITY, pddProblemLabels.mapIndexed { index, label ->
node("problem-$index", label, 20, 100 + index * 100, 900, 180 + index * 100)
})
}
if (!panel) {
if (reviewPage) {
return UiSnapshot(PDD, ACTIVITY, listOf(
@@ -906,7 +1079,7 @@ class PurchaseRehearsalExecutorTest {
node("panel-title", "确认款式", 20, 396, 300, 430),
))
}
val hideColor = hideColorAfterQuantitySet && quantity == 2L
val hideColor = hideColorAfterQuantitySet && quantity == 2L && !hiddenColorRestored
val hideSize = hideSizeAfterQuantitySet && quantity == 2L
val hideSummary = hideSelectedSummaryAfterQuantitySet && quantity == 2L
val displayedSummary = if (quantity == 2L && selectedSummaryOverrideAfterQuantitySet != null) {
@@ -925,8 +1098,20 @@ class PurchaseRehearsalExecutorTest {
}
if (!hideColor) {
nodes += node("scroll/color-heading", "颜色分类", 20, 410, 300, 450, parentPath = "scroll")
val selectedColor = if (quantity == 2L) selectedColorOverrideAfterQuantitySet ?: color else color
colors.forEachIndexed { index, value ->
nodes += node("scroll/color-$index", value, 20 + index * 220, 470, 200 + index * 220, 540, clickable = true, selected = color == value, enabled = !allSpecsUnavailable, parentPath = "scroll")
nodes += node(
"scroll/color-$index",
value,
20 + index * 220,
470,
200 + index * 220,
540,
clickable = true,
selected = selectedColor == value,
enabled = !allSpecsUnavailable,
parentPath = "scroll",
)
}
}
if (!hideSize) {
@@ -964,7 +1149,9 @@ class PurchaseRehearsalExecutorTest {
clicked += target.label
clickedPaths += target.path
if (target.path == "buy") {
if (openReviewOnBottomClick) reviewPage = true else panel = true
if (entryActionHasEffect) {
if (openReviewOnBottomClick) reviewPage = true else panel = true
}
return FreshActionResult.SUCCESS
}
when (target.label) {
@@ -974,7 +1161,7 @@ class PurchaseRehearsalExecutorTest {
if (result == FreshActionResult.SUCCESS || openPddOnFailedClick) browser = false
return result
}
"选择规格", "免拼购买" -> panel = true
"选择规格", "免拼购买" -> if (entryActionHasEffect) panel = true
in sizes -> {
sizeClickCount++
val result = sizeClickResults.removeFirstOrNull() ?: FreshActionResult.SUCCESS
@@ -1020,6 +1207,17 @@ class PurchaseRehearsalExecutorTest {
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = clickFresh(target)
override fun tapSpecFresh(target: SnapshotNode): FreshActionResult {
specTapCount++
if (specTapResult != FreshActionResult.SUCCESS || !specTapHasEffect) return specTapResult
when {
target.path in setOf("spec", "buy") -> panel = true
target.label in sizes -> size = target.label
target.label in colors -> color = target.label
}
return FreshActionResult.SUCCESS
}
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
quantity = value.toLong()
return FreshActionResult.SUCCESS
@@ -1031,8 +1229,13 @@ class PurchaseRehearsalExecutorTest {
return purchaseSwipeSucceeds
}
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean =
swipePurchase(direction, durationMs)
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
swipeInPaths += target.path
if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {
hiddenColorRestored = true
}
return swipePurchase(direction, durationMs)
}
override fun pullDownGoodsPage(): Boolean {
pullDownCount++
@@ -0,0 +1,72 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy
import cn.ilapage.goauto.agent.ui.PurchaseResultBubbleSession
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 PurchaseResultBubblePolicyTest {
@Test
fun `spec probe does not display a final result bubble`() {
assertNull(PurchaseResultBubblePolicy.create(69, "spec_probe_completed", "probeSpecs", "规格已回传"))
}
@Test
fun `failure includes the real step and sanitized reason for eight seconds`() {
val result = requireNotNull(PurchaseResultBubblePolicy.create(
69,
"failed",
"selectSpec",
"没有找到精确规格\n请重试 token=secret-value",
))
assertEquals("CG-69 失败于:选择颜色与尺码", result.title)
assertEquals("没有找到精确规格 请重试 token=[已隐藏]", result.message)
assertTrue(result.isFailure)
assertEquals(8_000L, result.durationMillis)
}
@Test
fun `unknown order result is presented as a failure requiring manual check`() {
val result = requireNotNull(PurchaseResultBubblePolicy.create(
70,
"order_result_unknown",
"readOrderResult",
"无法确认订单是否创建,请人工检查",
))
assertEquals("CG-70 待人工核对|读取订单编号和下单时间", result.title)
assertTrue(result.isFailure)
assertEquals(8_000L, result.durationMillis)
}
@Test
fun `successful order uses a short factual summary for three seconds`() {
val result = requireNotNull(PurchaseResultBubblePolicy.create(
71,
"order_created",
"readOrderResult",
"ignored",
))
assertEquals("CG-71 采购完成", result.title)
assertEquals("已获取订单编号和下单时间", result.message)
assertFalse(result.isFailure)
assertEquals(3_000L, result.durationMillis)
}
@Test
fun `new result and dismiss invalidate an older scheduled bubble`() {
val session = PurchaseResultBubbleSession()
val first = session.replace()
val second = session.replace()
assertFalse(session.isCurrent(first))
assertTrue(session.isCurrent(second))
session.dismiss()
assertFalse(session.isCurrent(second))
}
}
@@ -16,18 +16,22 @@ class PurchaseRetryPolicyTest {
}
@Test
fun `retry confirmation explains same task latest rule and no payment`() {
fun `ordinary retry uses same task new attempt and no payment`() {
val message = PurchaseRetryPolicy.confirmationMessage()
assertTrue(message.contains("当前任务"))
assertTrue(message.contains("最新采购规则"))
assertFalse(message.contains("新采购任务"))
assertTrue(PurchaseRetryPolicy.usesInPlaceReset(continuing = false))
assertTrue(message.contains("复用原采购任务"))
assertTrue(message.contains("新一次执行"))
assertTrue(message.contains("当前有效采购规则"))
assertTrue(message.contains("任务快照保持不变"))
assertTrue(message.contains("待付款订单"))
assertTrue(message.contains("不会支付"))
assertFalse(message.contains("新采购任务"))
}
@Test
fun `continue confirmation remains a new purchase task`() {
val message = PurchaseRetryPolicy.confirmationMessage(continuing = true)
assertFalse(PurchaseRetryPolicy.usesInPlaceReset(continuing = true))
assertTrue(message.contains("新采购任务"))
assertTrue(message.contains("替代商品"))
assertTrue(message.contains("不会支付"))
+8 -4
View File
@@ -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: 6207da4ffac6b5821eda7869b9ea335b7585826d
synchronized_at: 2026-08-31T16:06:21Z
wiki_revision: 7468b9fbdd4d0bbbb9a73580c22ec868b3085753
synchronized_at: 2026-09-05T07:16:39Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
@@ -21,16 +21,20 @@ synchronized_at: 2026-08-31T16:06:21Z
| 后续设计范围 | 从 SYB 创建采购任务、创建待付款订单、物流采集与自动回填 |
| 预计规模 | 20 台 Android;每天约 100 个采集任务、200 个采购任务 |
## 项目治理模式
GoAuto 默认采用轻量治理:文案、注释、格式、局部样式或布局、预期行为明确的小 Bug,以及不改变接口、数据结构、权限和安全边界的单模块低风险调整可以直接实施,无需为了留痕补建工单。完整独立需求、新页面、跨模块功能,以及涉及 API、数据结构、权限、安全、迁移或范围不明确的变化必须建立单元工单。采购、创建订单、真实个人或生产数据、权限、安全、并发、迁移、删除、发布和不可逆操作始终升级为高风险,必须具备对象和范围明确的人工授权;已有有效授权时不机械重复确认,范围或环境发生实质变化时重新确认。永久禁止付款以及设备、隐私、AI 和真机安全红线不可裁剪。
## 建设基线
| 基线 | 来源与版本 | 许可证 / 使用方式 | GoAuto 适配 |
|---|---|---|---|
| DevHarness | `D:\OPC\dev_harness`,目标提交 `4bbacf4d7fb265984396bb5589c544105043fa0b` | 开发流程与文档模板 | 2026-08-27 升级(#114):在 #76 已采用的单人工单事实源基础上,增量加入部署模板、Gitea MCP 与最小工单读取、线上原型默认审核与按需 HTML 导出、PowerShell UTF-8/ExecutionPolicy 边界;继续保留 GoAuto 专用安全与设计门禁 |
| DevHarness | `D:\OPC\dev_harness`,目标提交 `ecab899` | 开发流程与文档模板 | 2026-09-05 升级(#221):在既有单人工单事实源基础上,选择性加入轻量治理、明确授权边界、中文默认沟通、Windows PowerShell 安全规则,以及按 revision 增量同步与显式深度检查;继续保留 GoAuto 专用高风险与安全门禁 |
| 服务端 | `go-admin` v2.3.0 | 上游开源管理端基线;升级时复核许可证和安全公告 | 保留认证、菜单、配置和管理端基础能力,新增 GoAuto 业务模块 |
| 管理端 | `go-admin-ui` v3.0.0,`web/package.json` 标注 MIT | Vue 管理界面基线 | 保留应用外壳与通用组件,新增 GoAuto 页面 |
| Android | 原生 Kotlin Agent | 自研业务客户端 | 通过管理员明确配置的 HTTP 或 HTTPS Origin 直连服务端,不保留 Windows 桌面 Client/ADB 作为生产拓扑 |
升级必须比较当前记录的目标提交与新的明确提交,不能笼统复制“最新版”。本次上一基线为 `bfdf648962d11a8024f62768380d8571e1f45f68`,目标为 `4bbacf4d7fb265984396bb5589c544105043fa0b`,区间共 13 个提交;更早基线 `b1f500128d6eb100985792d4a715db8b6b5ae203` 的适配见 #47。模板内容一律按 GoAuto 事实改写:不复制 DevHarness 的项目事实、任务记录、占位部署参数或历史归档;`harness.py` 继续校验 GoAuto 实际核心页面与产品 README,GoAuto 更严格的付款、订单、设备、数据和真机门禁继续优先。
升级必须比较当前记录的目标提交与新的明确提交,不能笼统复制“最新版”。本次上一基线为 `4bbacf4d7fb265984396bb5589c544105043fa0b`,目标为 `ecab899`,选择性适配其后的 7 个提交;更早基线适配见 #47 与 #114。模板内容一律按 GoAuto 事实改写:不复制 DevHarness 的项目事实、任务记录、占位部署参数或历史归档;`harness.py` 继续校验 GoAuto 实际核心页面与产品 README,GoAuto 更严格的付款、订单、设备、数据和真机门禁继续优先。
## 交付单元
+22 -3
View File
@@ -2,12 +2,18 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Development-Workflow
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.-
wiki_revision: 3167457d79824afe532dd5560990af8a485a57d1
synchronized_at: 2026-08-27T09:37:51Z
wiki_revision: 62ddbe4469740c02ce4a6ca2fd1966a89a79322f
synchronized_at: 2026-09-05T07:16:44Z
<!-- gitea-wiki-mirror:end -->
# 开发工作流
## 语言与术语
- 用户可以使用中文、英文或合理的中英混合语言交流;默认使用中文分析、回复、编写工单和维护内部项目文档。
- 代码标识符、命令、参数、路径、文件名、API 名称、协议名、日志和错误原文保持原样;必要时补充简短中文解释。
- 用户明确要求某次回复或交付物使用其他语言时,按该次要求执行,不改写接口契约或影响搜索和执行的原文。
## 事实来源
| 信息 | 唯一事实来源 |
@@ -18,7 +24,7 @@ synchronized_at: 2026-08-27T09:37:51Z
| 源码、迁移、测试、版本绑定分析、本地原型和核心 Wiki 镜像 | Git |
| 可编辑交互设计 | QuantUX;App ID、版本、链接和确认状态记录在工单 |
核心页面通过 `wiki-docs.json` 显式映射,固定执行 Wiki → `docs/` 单向同步。既有 Wiki 任务归档和 `docs/task/` 只作历史兼容;标准任务不创建或导出,只有用户明确要求专项快照时才使用 `archive` / `export`。
核心页面通过 `wiki-docs.json` 显式映射,固定执行 Wiki → `docs/` 单向同步。日常 `sync` 与 `sync --check` 先比较页面 revision,revision 未变化时不重复下载正文;疑似镜像损坏或需要完整核对时显式使用 `sync --deep-check`。既有 Wiki 任务归档和 `docs/task/` 只作历史兼容;标准任务不创建或导出,只有用户明确要求专项快照时才使用 `archive` / `export`。
## 权威源与事实边界
@@ -53,6 +59,19 @@ synchronized_at: 2026-08-27T09:37:51Z
- 每个核心页面写入后必须在线回读并取得 revision。页面缺失、回读失败或没有 revision 时停止初始化。
- 产品编码前运行 `python dev_scripts/harness.py sync --verify`;全部成功才表示初始化完成。
## 项目治理模式与不可裁剪底线
GoAuto 默认采用轻量治理。文案、注释、格式、局部样式或布局、预期行为明确的小 Bug,以及不改变接口、数据结构、权限和安全边界的单模块低风险调整可以直接实施,无需为了留痕补建工单。完整独立需求、新页面、跨模块功能,以及 API、数据结构、权限、安全、迁移或范围不明确的变化必须建单。
采购、创建订单、真实个人或生产数据、权限、安全、并发、数据库迁移、删除数据、发布和其他不可逆操作始终升级为高风险。无论任务采用何种最小门禁,凭据保护、永久禁止付款、个人与生产数据最小化、设备互斥、Agent 禁止 OCR/VLM、控件树与整屏截图禁存、工作区保护、真实测试和人工验收均不可裁剪。
### 明确授权后的执行
- 当前聊天中用户给出的明确指令,或 Gitea 工单中能够归属于有权人工的明确授权,可以作为执行依据,不要求把同一授权重复复制到工单后再次确认。
- 授权必须能识别操作、对象和范围;Agent 自动生成的工单、草稿、摘要或对用户意图的转述不能单独构成人工授权。
- 获得有效授权后,只核对准确目标、授权范围和当前状态等最小必要前提,不得仅因操作不可逆而重复询问或拒绝。
- 授权不自动覆盖相邻对象或后续任务;环境、对象、范围或影响发生实质变化时重新确认。平台自身强制的审批、安全策略或权限限制继续有效。
## 工单与设计证据双门禁
正式实施前先判断是否需要工单,再判断需要什么设计证据。工单不能替代原型确认,原型也不能替代技术方案、安全检查和单元工单。
+26 -11
View File
@@ -2,16 +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: de7e400503d6e7b9c335657114893de345bfead5
synchronized_at: 2026-08-31T03:33:36Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
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: dc5ccd2104e8ded715c0c0bb23d3827dc9a7fd7a
synchronized_at: 2026-08-29T01:39:35Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:29:50Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -56,7 +48,7 @@ Android Portal/Agent
- Android 本地互斥与服务端原子领取共同保证单设备串行。
- 正式采购在 Android 本地 SQLite 事务中先保存不可逆状态、稳定服务端请求 ID 和脱敏最终确认快照,再通知服务端并只允许一次创建订单点击;重启后只重放服务端标记、只读核单或上报,不再次点击。
- 原始控件树和截图不持久化;Android Agent 端第一期不使用 OCR/VLM。服务端 SYB 登录验证码识别是唯一例外,见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48)。
- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色;尺码自动匹配只预览格式统一后的唯一确定结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。
- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色。Admin 商品详情的“一键匹配颜色和尺码”在服务端统一计算两个维度:保留当前仍有效的已确认映射,将唯一确定匹配及达到阈值、具备理由且候选仍有效的 AI 匹配,在重新校验规格上下文后于同一事务直接写为 `confirmed`,无需人工确认;低置信度或无结果保持未匹配,Provider 异常或上下文变化时不写入任何本次结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。
## 最小业务数据
@@ -318,3 +310,26 @@ PddProductDetailCollector
- `GET /api/admin/v1/pdd-products/{productId}/related-syb-products` 返回统一扁平分页,参数为 `page`、`pageSize`(默认 20、最大 100)、`scope=actionable|all`(默认 actionable)和可选 `shopeeProductId`。管理员和采购员可读,其他角色由 Casbin 拒绝。
- `purchase.Service.ProcessStages` 是 SYB 页和 PDD 关联订单共用的只读阶段入口;批量加载、不调用 AI。当前采购规则缺失或无效时仍返回基础关联、采集和已有任务事实,依赖规则才能创建的行明确标记不可采购。
- Web 的 PDD 商品详情把“创建备货采购”保留在顶部,把“关联订单继续采购”放在关联商品区块;订单行使用统一分页,并复用既有 `batch-preview` / `batch` 创建路径。
## 蝦皮规格自动匹配批处理(#195)
- 服务端在 `server/app/goauto/shopeeproduct/auto_match_batch.go` 复用单商品“一键匹配颜色和尺码”原子服务,定时任务与 Admin 手动执行共用同一批处理入口。
- `shopee_spec_auto_match_run` 保存触发来源、稳定 `requestId`、运行摘要和可空唯一 `active_slot`;活动槽与租约保证多实例、定时和手动同时触发时全局最多一个运行批次。
- `shopee_spec_auto_match_work_item` 按蝦皮商品唯一保存输入指纹、尝试次数、下次尝试时间和逐商品租约。已完成或低置信度/无结果的相同输入不重复调用 AI;规格、关联或 AI 设置更新时间变化后才允许重新处理。
- 迁移 `1788290000000_shopee_spec_auto_match.go` 幂等创建两张表并写入调用目标 `GoAutoShopeeSpecAutoMatch`。系统任务默认关闭,默认 Cron 为每小时第 15 分钟、每批最多 20 个商品。
- Admin 蝦皮商品列表通过异步手动接口启动同一批次并轮询最近运行摘要;该入口仅管理员可用,不创建采购任务、订单,不调用 Android Agent,也不执行付款。
## SYB 异常规格 AI 定时解析(#198)
- `server/app/goauto/sybimport/ai_parse_batch.go` 以 SYB 明细为单位执行“确定性重解析 → 封闭候选 AI 解析”,与 #195 的“蝦皮规格 → PDD 规格”任务保持独立。
- `syb_spec_ai_parse_run` 保存全局批次、活动槽、租约与结构化计数;`syb_spec_ai_parse_work_item` 按 `syb_product_id` 唯一保存输入指纹、尝试次数、冷却和逐行租约。
- `syb_product` 的 `ai_confirmed`、置信度、限长理由、确认时间和隐藏输入指纹记录 AI 确认事实;`parse_status` 仍只记录确定性解析器结果,`manually_confirmed` 仍只代表人工决定。
- Provider 只接收单条 `productSpec` 和关联蝦皮商品的颜色/尺码候选;返回值必须逐字属于对应候选。采购可信门禁接受仍有效的 AI 确认,但候选消失后立即 fail-closed。
## 定时任务持久化执行历史(#199)
- `server/app/jobs/execution_log.go` 为 Exec 与 HTTP 两类调度入口统一记录一次执行生命周期;`server/app/jobs/models/sys_job_execution_log.go` 对应表 `sys_job_execution_log`,保存执行标识、任务/调用目标快照、定时触发类型、开始/结束时间、耗时、`running` / `succeeded` / `failed` / `interrupted` 状态及脱敏错误码和摘要。
- 服务启动时按当前“每个数据库一个调度器实例”的拓扑,把上次进程遗留的 `running` 记录安全收敛为 `interrupted`;任务删除后历史仍保留并可只读查询。
- `GET /api/v1/sysjob/:id/execution-logs` 由 `server/app/jobs/service/execution_log.go`、`apis/execution_log.go` 和 `router/sys_job.go` 提供任务级分页、状态与开始时间过滤;沿用隐藏菜单 `JobLog` 的角色菜单绑定和精确 GET 权限。Web 入口为 `web/src/views/schedule/index.vue` 的单选“日志”按钮,详情页为 `web/src/views/schedule/log.vue`。
- 执行历史明确不保存任务参数、AI Provider 地址或密钥、第三方原始响应和业务原始载荷;当前不提供删除、保留期限自动化、WebSocket 实时流或立即执行动作。
- 追加迁移为 `server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log.go`:创建执行历史表、登记只读 API、关联 `JobLog` 菜单,并只给迁移前已绑定该菜单的角色补充精确 Casbin 权限。
+47 -25
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: 9b195f3c578f980b808e6eb28f36e7bf5294e75b
synchronized_at: 2026-09-03T01:55:28Z
wiki_revision: 66dddbe756fd8b3cd89fc3d0a2fda69deac74af3
synchronized_at: 2026-09-05T08:45:24Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -41,6 +41,8 @@ synchronized_at: 2026-09-03T01:55:28Z
- 一行对应 SYB 一条明确的商品/颜色/尺码/数量明细;唯一键为「蝦皮订单号 `code` + 来源明细 `id`」组合,不是全局唯一 ID(未在多货运单样本中验证过全局唯一性)。
- `productSpec` 自由文本解析:按最后一个逗号切分为颜色/尺码 → 剥离【】备注 → 判断残留分隔符;解析状态分 `success`/`uncertain`/`failed`,永不猜测原文中不存在的颜色或尺码。
- 采购放行不以 uncertain 单独阻断:success 与已提取出至少一个非空颜色或尺码的 uncertain 明细,都可继续进入规格匹配和采购预检;只有 failed 或颜色、尺码均为空的异常明细才要求人工处理。uncertain 状态与解析说明继续保留供审计,不会自动猜测规格。
- 只有 `success` 状态的解析结果会合并进虾皮商品档案的规格值(来源标记 `import`);`uncertain`/`failed` 只停留在明细行上供人工复核,不污染共享档案。
- 金额单位按接口分别定义:`/am/stock/detail/listByStock` 的 `productPrice` 是十进制金额,与 `/am/stock/list` 的 `amtOrder`(×100 整数)不是同一套换算,不可共用。
- 关联虾皮商品档案:命中存活记录直接关联;命中软删除记录则复活并保留原有人工映射;均不存在时创建最小档案。命中既有档案时只补写为空的参考图与售价,不覆盖人工修改过的标题、店铺和映射。
@@ -62,6 +64,7 @@ synchronized_at: 2026-09-03T01:55:28Z
## SYB 店铺过滤
- `syb_shop` 保存允许导入的 SYB 店铺;店铺名比较前统一去除首尾空白、转换全角/半角字符并忽略大小写。规范化后同名的店铺只能保留一条有效记录。
- 版本迁移会对历史存活店铺回填 `normalized_name = Normalize(display_name)`;回填只改匹配键且可重复执行。若两个存活店铺回填后会得到同一键,迁移必须整体失败并保留原数据,管理员先人工消除歧义后再执行,不能静默合并、删除或改变店铺启用状态。
- 店铺可以从 SYB 真实货运单列表发现,也允许管理员手工补充。只有管理员可以新增、改名、启停和软删除,采购员等其他角色只读。
- 没有任何启用店铺时,导入必须在读取凭据、建立会话、验证码 OCR 和任意 SYB 网络请求之前失败,并给出“请先启用店铺”的可读提示。
- 同步必须先拉取并校验当天原始全量列表的总数、分页和唯一 ID,再按本次同步开始时固定的启用店铺快照过滤;过滤不能降低完整性校验的请求范围或容量上限。
@@ -73,7 +76,7 @@ synchronized_at: 2026-09-03T01:55:28Z
- 导入由管理员创建,创建成功后立即转入后台执行;页面关闭不取消任务。采购员等其他已登录角色可以查看同步记录,不能开始导入。
- 系统同一时刻只运行一个 SYB 导入任务。`syb_sync_run` 的唯一执行槽负责跨进程互斥,内存锁减少同一进程内的竞争;服务重启后遗留的执行中记录标记为“已中断”。
- 导入失败或中断时保留已写入商品,记录明确的失败原因和已处理进度;重新导入相同范围按「订单号 + 明细 ID」覆盖,不产生重复商品。
- 每条同步记录保存本次启用店铺集合的 SHA-256 哈希以及各店铺“已导入/已跳过”数量,不保存账号、密码、Cookie、验证码图片或 SYB 原始响应。
- 创建同步记录时冻结本次启用店铺的规范化匹配集合、展示名称快照及 SHA-256 哈希;后台执行必须只使用这一份快照,不得在开始后重新读取 `syb_shop`。同步详情保存并返回快照可用标记、快照店铺名称、哈希及各店铺“已导入/已跳过”数量。#212 之前的历史记录只有哈希和统计,明确标记为无完整快照。上述记录不保存账号、密码、Cookie、Token、验证码图片或 SYB 原始响应。
- 后台任务创建前的内部失败仍向普通用户显示“服务端处理失败”,但使用稳定错误码区分阶段:店铺预检为 `SYNC_SHOP_PREFLIGHT_FAILED`,同步记录创建为 `SYNC_RUN_CREATE_FAILED`。服务端只记录阶段和经过脱敏、截断的底层原因,不记录请求体、凭据、Cookie、Token、验证码或原始响应。
## 采集规则
@@ -84,12 +87,14 @@ synchronized_at: 2026-09-03T01:55:28Z
- 删除采用软删除;删除后不能创建新任务,但已有任务继续执行自身快照。
- 商品详情步骤应声明精确 `activityName`,并与包名和唯一控件共同作为页面证据;进入 PDD 登录 Activity 必须返回 `PDD_LOGIN_REQUIRED`,不能提交采集成功。
- 唯一文字节点不可点击时,Agent 只可点击其最近的可点击父容器;不得改点兄弟节点或相似文字。
- 商品页规格入口只接受带明确选择语义的规格摘要,或底部真实“购买/拼单”文字按钮;评价、评论、晒单、问答区域及其父容器一律排除。商品评价页与详情页共用 Activity 时,首次误触只允许安全返回并重新定位一次,重复误触明确失败;点击无变化、误入评价页和面板结构无法确认使用不同错误码。
- 商品页规格入口只接受带明确选择语义的规格摘要,或底部真实“购买/拼单”文字按钮。若“请选择”和颜色/尺码语义分散在同一可点击父容器的子节点中,也只能在该容器唯一、位于非底部动作区且同时具备两类语义时作为规格入口;规则别名只能收窄已识别的安全候选,不能按页面原始文字扩张。底部购买入口在通过全部安全过滤后仍存在多个候选时,按最右优先(`centerX` 降序,并列时可点击面积升序)确定性选出唯一入口,不再因候选不唯一直接失败;该情形记为 `bottom_purchase_rightmost`,唯一候选仍记为 `bottom_purchase`。“单独购买”与“发起拼单”打开同一规格面板,但对应不同成交形态与价格,最右优先是经人工确认的取舍。评价、评论、晒单、问答、订单、提交订单、支付和付款区域及其父容器一律排除。商品评价页与详情页共用 Activity 时,首次误触只允许安全返回并重新定位一次,重复误触明确失败;点击无变化、误入评价页和面板结构无法确认使用不同错误码。
- v2 规则使用类型化动作和固定阶段钩子。已由 Agent 支持的选择器、别名、超时、滑动方向和有限次数可以只更新规则;新增动作类型或页面算法才需要升级 Agent。
- v2 任务首次未进入精确商品详情页时,可以按规则显式重开同一浏览器 URL 一次;登录、验证码、风控、无效链接和详情页内采集失败不触发该恢复。
- 假售罄恢复只使用主商品规格/购买强证据判断页面是否正常;顶部“相似商品”可以作为受控兜底证据,推荐卡片的标题、价格和销量不得冒充主商品证据。
- 商品规格遍历必须用已识别规格节点锁定横向颜色容器和纵向面板容器;颜色按视觉行蛇形遍历,滑动完成后重新读取节点,尺码只读并允许在标题滚出后沿已锁定容器续页。
- PDD 已选规格的订单确认形态只可由详情页、选择摘要、唯一数量控件、支付区、唯一底部提交动作和唯一主要滚动容器的组合证据确认;底部提交动作只读且永久禁止点击。Agent 只在该已确认容器内最多三次向下拖动回顶,每次重新读取节点;“参考分类”作为尺码的精确标题别名处理。
- PDD 完整规格选择器若无主滚动容器且尚未选择规格而没有已选摘要,仍可识别为非滚动规格面板,但必须同时出现至少两个已解析维度、至少两个可选项、唯一数量控件和唯一底部订单动作;缺少其中任一强证据时保持未识别。该订单动作仍仅作页面证据,永久禁止点击。
- Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则只能使用独立审核的创建订单能力。当前项目不实现支付动作、入口或测试;支付、下单和订单相关文字可以作为只读页面证据配置,但不得成为点击目标。后续支付能力必须单独评估并至少具备显式能力位、服务端开关、单笔金额上限和人工授权。
## 采集任务
@@ -135,27 +140,27 @@ synchronized_at: 2026-09-03T01:55:28Z
- 演练规则在服务端契约层拒绝改地址、创建订单和核单动作;正式动作还必须通过版本化能力协商。规则只包含类型化动作,禁止任意脚本。
- 价格保护保存参考单价、最低单价、最高单价和币种,执行时以 PDD App 实际单价判断;价格越界明确失败,不考虑优惠券。
- Admin 从 SYB 当前页选择商品,可在“创建采购”和“重新解析”两种批量用途间切换;采购模式只允许选择预检合格行,表头全选不跨页。批量预检和正式创建均最多 100 条并逐条重新校验,每条 SYB 明细只创建一个独立任务;部分失败不回滚其他成功项。
- SYB 商品列表在列表数据返回后立即展示,采购准备状态独立异步加载;只读批量预检只执行批量数据库读取、已确认映射和本地确定性识别,禁止调用 AI Provider。未保存的确定性结果仍显示“规格待匹配”,不能作为“可创建采购”的依据。SYB 批量创建采购按最新数据完整复核,只有目标颜色和尺码都已保存为确认映射,且共同命中最近一次成功或部分成功采集中的完整可售 SKU 组合时才允许创建;否则必须先执行批量 AI 匹配或人工确认,不能把未匹配明细带入采购任务内的人工匹配步骤。
- SYB 商品列表在列表数据返回后立即展示,采购准备状态独立异步加载;只读批量预检只执行批量数据库读取和本地确定性识别,禁止调用 AI Provider。自 #215 起,未保存或已失效的长期规格映射不再阻断创建采购;满足商品关联、PDD 状态、价格、规则、设备和订单安全门禁后即可创建任务,每个新 SYB 采购任务都在首个 attempt 以当次 PDD 页面候选重新决策。
- SYB 商品页的 PDD 采集资格独立于采购处理阶段:只要已关联的 PDD 商品未停用、没有 `pending` / `running` 采集任务且存在可用采集规则,即可创建采集任务;因此已完成采集并进入“可创建采购”的商品也可以重新采集。相同 PDD 商品在批量创建前按商品去重,服务端创建时仍按最新状态复核。
- 批量创建读取管理员选定的当前采购规则并重新执行契约校验,每条任务保存不可变快照;当前规则缺失或无效时明确阻断,不回退到代码常量。设备默认人工指定,也可以留空由符合能力的空闲设备领取。
- 批量创建的价格保护来自 PDD 商品档案而不是 SYB/Shopee 的 TWD 售价:当前采购规则可用 `priceGuard.minRatio`(0.1~1.0)和 `maxRatio`(1.0~3.0)配置比例,缺省仍为 0.2 / 1.5;最低价向下取整、最高价向上取整到人民币分。已确认颜色映射时按该颜色计算,待规格探测时以全部可用颜色的最低/最高价计算,参考价取最高价;没有可用颜色价格时不能创建。
- 地址后缀为 `_cg{purchase_task.id}`。选好规格和数量后,Agent 仍停留在当前规格/下单面板;若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;脱敏手机号文字本身不可点击时,仍以该唯一文字节点的中心坐标执行一次精确手势点击,不沿用可能覆盖整个下单面板的可点击祖先。“修改”“保存”和“提交订单”等文字节点即使依赖可点击父节点,也必须保留原始文字节点作为每次重新定位的锚点,父节点只用于验证存在可点击路径。近乎完全重叠的无障碍重复节点按一个目标处理,仍有多个独立目标或页面切换超时则明确失败。按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。
- 地址后缀为 `_cg{purchase_task.id}`。精确规格、数量、价格和摘要核验通过后,若仍处于已识别的规格面板,Agent 只允许点击唯一、可见、启用、可点击且文案精确命中“确定/确认”别名的规格确认控件;不得点击提交订单、确认购买、付款、支付、地址修改或地址保存控件,也不使用中心手势兜底。点击后必须重新读取并确认已出现订单确认页或地址入口强证据,否则明确失败。进入订单确认页后,若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;脱敏手机号文字本身不可点击时,仍以该唯一文字节点的中心坐标执行一次精确手势点击,不沿用可能覆盖整个下单面板的可点击祖先。“修改”“保存”和“提交订单”等文字节点即使依赖可点击父节点,也必须保留原始文字节点作为每次重新定位的锚点,父节点只用于验证存在可点击路径。近乎完全重叠的无障碍重复节点按一个目标处理,仍有多个独立目标或页面切换超时则明确失败。按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。
- 地址编辑页可以同时存在收货人、手机号和详细地址等多个输入框;Agent 只选择与“详细地址”标签纵向重叠且位于其右侧的唯一输入框,不能用页面输入框总数或顺序猜测。
- 点击创建订单前,Agent 必须先在本地事务保存 `order_submit_started`、不可逆时间、稳定请求 ID 和不含地址全文的最终确认快照,再用同一请求 ID通知服务端;两侧成功后才允许精确点击唯一创建订单按钮一次。
- 进入不可逆边界后,进程重启、断网、点击结果不明或无法取得唯一订单号/下单时间时只允许只读核单并进入 `order_result_unknown`,禁止再次点击;任务与订单正式关联仍以完整 PDD 订单号为准。
- 创建订单后出现 Android 多微信应用选择器时,Agent 只有在系统包和 `ChooserActivity` / `ResolverActivity` 白名单、已知选择器标题、微信候选三类证据同时成立时,才允许按一次返回,绝不点击微信候选。若已进入真实微信包 `com.tencent.mm`,Agent 不点击、不输入、不登录、不支付、不强制停止微信,只允许用一次无参数 PDD 启动 Intent 把既有 PDD 任务栈拉回前台;第二次仍见微信或拉起失败即保持结果未知。回到 PDD 已知支付 Activity 或明确支付动作页面后最多再返回一次,再只读读取唯一订单号和下单时间;任一页面不在白名单、恢复动作重复、无法到达订单详情或结果不唯一时保持 `order_result_unknown`,禁止重下单或支付。
- 创建订单后出现 Android 多微信应用选择器时,Agent 只有在系统包和 `ChooserActivity` / `ResolverActivity` 白名单、已知选择器标题、微信候选三类证据同时成立时,才允许按一次返回,绝不点击微信候选。若已进入真实微信包 `com.tencent.mm`,Agent 不点击、不输入、不登录、不支付、不强制停止微信,只允许用一次无参数 PDD 启动 Intent 把既有 PDD 任务栈拉回前台;第二次仍见微信或拉起失败即保持结果未知。回到 PDD 已知支付 Activity 或明确支付动作页面后最多再返回一次,再只读读取唯一订单号和下单时间;下单成功页仅可点击唯一、精确的“查看订单”或“订单详情”入口一次,进入 PDD 订单详情后才允许有限向上手势下翻读取折叠内容。PDD 页面出现“待付款”“待支付”“订单编号”或“下单时间”等订单结果信号时,该只读信号优先于复用的支付 Activity 名称:允许在唯一主要纵向容器内有限下翻并重新读取,仍不得点击支付;没有任何订单结果信号的支付页或未知页面不得盲目滑动。入口缺失或不唯一、点击后未出现详情证据、任一页面不在白名单、恢复动作重复或结果不唯一时保持 `order_result_unknown`,禁止重下单或支付。
- Agent 本地 SQLite/Outbox 负责断网和重启恢复,服务端以 `task_id + task_attempt_id` 幂等接收并保存最终事实。
- 人工支付复核只记录 `paid` / `unpaid`;系统不执行或识别支付。快递单号与回填状态属于采购任务,后续物流工单实现。
- 采购任务领取时同时占用设备租约和可选 PDD 账号租约;租约过期后才可释放并重新领取。设备还存在采集任务时不能领取采购任务。
- 采购规格由服务端按顺序决策:先使用已确认的人工映射;否则仅在同一规格角色的可选 PDD 原始标签中做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算);仍无唯一结果才调用已启用的服务端 AI。AI 必须返回候选集中的原始标签,候选不完整、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。
- 自 #215 起,每个新 SYB 采购任务无论是否存在已确认长期映射,都先由 Agent 在当次 PDD 规格面板内有界遍历颜色和尺码并回传原始候选。服务端只以任务冻结的 SYB 目标规格与当次候选先做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算),仍无唯一结果才调用已启用的服务端 AI。AI 必须逐字返回当次候选集中的原始标签;候选不完整、集合外返回、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。
- Agent 选择服务端下发的精确颜色或尺码时,只能在已确认打开的规格面板内有限纵向滑动、每次重新读取可选节点并按完整原始文字点击;连续没有新证据或达到上限即停止,不得点击相近规格。第一趟探测并固化规格后,第二趟仍无法精确选择而再次提交探测时,服务端必须明确失败并释放活动槽,保留第一次决策证据,禁止清空决策、循环派发或进入地址与创建订单动作。
- 采购 Agent 在确认进入 PDD 商品页后、打开规格前,复用采集侧的假售罄识别;规格面板已打开且当前解析到的规格值全部不可选时也按售罄处理。两种情况都只允许关闭规格面板后对商品页执行一次有界恢复(默认下拉 2 次、间隔 1000 毫秒、等待 2000 毫秒),恢复动作失败或恢复后仍售罄时返回 `PDD_GOODS_SOLD_OUT`,恢复后商品页证据丢失时按页面规则不匹配失败;不得继续选择规格、修改地址或创建订单。该失败码继续进入既有替代商品资格判定。
- 规格映射不完整、已保存的确认映射对当前 PDD 档案失效、确定性匹配失败、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;自 #190 起前两种情况不再拒绝创建,而是把 `spec_source` 降级为 `unresolved` 并交由规格探测解析,规则不具备该能力时仍然拒绝创建;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。Android 不自行匹配或猜测;其只接收服务端已经固化的精确原始规格标签。
- 所有新 SYB 采购任务的规则都必须具有 `purchase.spec-probe.v1`;创建时任务级 `mappedColor` / `mappedSize` 为空且 `spec_source=unresolved`,长期映射和 PDD 档案候选都不能跳过首趟探测。第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟;探测阶段从服务端拒绝进入 `order_submit_started`。Android 不自行匹配或猜测,只在第二趟接收并精确选择服务端固化的当次 PDD 原始标签。
- Agent 提交的相同 attempt 最终结果只能写入一次;相同请求重放返回原事实,不同内容拒绝覆盖。`order_result_unknown` 不参与自动派发,只能人工解除。
- 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。已标记为已支付的订单不能授权或创建重新采购任务。
- 人工回填候选只允许从已支付订单选择;同一 SYB 明细后来选择的订单覆盖旧候选,但不删除旧订单事实。
- Admin 采购管理只查看和处理已有任务,不提供创建入口或支付按钮;单条和批量采购任务都从 SYB 商品列表发起。订单结果未知时必须先人工核对并解除;处于该状态时页面不提供重新采购授权。
- 采购失败任务可在采购管理当前页批量勾选重试,最多 100 条。重试不修改旧任务,而是用当前 SYB/PDD 档案、当前规格映射、当前价格保护和当前采购规则创建新的 `pending` 任务,并生成新任务编号与地址后缀;来源蝦皮订单号沿用失败任务的不可变快照。
- 采购失败任务可在采购管理当前页批量勾选重试,最多 100 条。重试不修改旧任务,而是用当前 SYB/PDD 档案、目标规格、当前价格保护和当前采购规则创建新的 `pending` 任务,并生成新任务编号与地址后缀;来源蝦皮订单号沿用失败任务的不可变快照。新任务不复用旧任务或长期档案的执行规格,仍必须先完成当次真机规格探测。
- 只有正式采购、未进入不可逆边界、没有订单号或下单时间、且仍是同一 SYB 最新记录的 `failed` 任务可重试。原设备离线、停用、忙碌或能力不足时该项失败且不自动换机;未指定设备时仍由空闲设备领取。
- 批量重试逐项处理并允许部分成功;同一请求幂等重放不会重复创建。失败任务不再使用一次性重新采购授权,该授权只保留给已经创建过订单且满足条件的任务。
@@ -251,16 +256,14 @@ synchronized_at: 2026-09-03T01:55:28Z
## Agent 受控重试采购
- 当前设备只可就地重跑自身最近 30 天内、服务端标记 `retryable=true` 的正式采购失败任务;列表和详情都只能发起单任务重试,不支持多选、批量或自动重试。
- 普通“重试采购”复用原 `purchase_task.id`,不新建任务,不改变商品、虾皮/SYB 身份、目标规格、已映射规格、价格保护、地址后缀与其他业务快照。重试只把任务恢复为 `pending`,清理错误、租约和运行守卫。
- 重试时刷新服务端当前采购规则及其类型、schema 和能力要求。#127 尚未实施前,当前规则仍为服务端内置规则;#127 完成后才切换为数据库单例设置,不能在本工单提前引入第二事实源。
- 新规则下发前必须重新校验原设备在线、空闲且具备全部能力。规则不可用或能力不匹配时拒绝重试,原任务保持失败状态。
- 已出现 `order_submit_started` 证据,或存在不可逆时间、订单提交请求、PDD 订单号、下单时间的任务一律拒绝就地重试,并提示走既有“授权重新采购”流程,防止重复下单。
- 每次成功受理就地重试都会为同一任务预建一个新的 `pending` attempt,保存本次规则哈希;正常 Start 复用并转为 `running`。因此尝试次数、每次规则哈希和失败原因均可追溯,且不限制人工重试次数。
- `requestId` 按“任务 + 请求”幂等;相同请求重放不重复修改任务或增加 attempt。
- Agent 详情显示已尝试次数与上次失败原因。确认界面必须说明同一任务使用最新规则重跑、可能创建待付款订单且系统不会支付。
- “继续采购”与普通“重试采购”语义不同:替代商品匹配完成后的“继续采购”继续调用既有 `AgentRetry → BatchRetry → Create`,保留旧任务并按当前替代商品档案创建新任务;Admin 批量重试同样继续创建新任务。二者均不得改成就地更新旧任务。
- 取消订单、修改既有订单和支付仍禁止;真机重跑可能进入创建待付款订单流程,执行前必须再次取得人工授权。
- 当前设备只可重试自身最近 30 天内、服务端标记 `retryable=true` 的正式采购失败任务;列表和详情都只能发起单任务重试,不支持多选、批量或自动重试。
- 普通“重试采购”调用就地 `/reset`:复用原 `purchase_task.id`,只新增 attempt;新 attempt 使用当前有效采购规则和最新版 Agent 代码,商品、目标规格、执行规格、数量、价格、地址及既有规格决策等业务快照保持不变。
- 就地重试不恢复已经消耗的真机规格探测资格;目标规格存在但对应执行规格为空时必须拒绝,不能进入正式采购阶段。需要按替代商品或已变化业务规格重新决策时,必须走明确的新任务流程。
- 已出现 `order_submit_started` 证据,或存在不可逆时间、订单提交请求、PDD 订单号、下单时间的任务一律拒绝重试,并提示走既有“授权重新采购”流程,防止重复下单。
- `requestId` 按“任务 + 重置请求”幂等;相同请求重放返回同一 task ID 和 attempt,不重复递增。
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认和成功反馈必须说明复用原任务、新增一次执行、使用当前规则但业务快照不变、可能创建待付款订单且系统不会支付。
- `/reset` 不得把“目标规格存在但对应执行规格为空”的任务恢复到正式采购阶段;此类异常快照必须拒绝,并提示使用明确的新任务流程。
- 替代商品匹配完成后的“继续采购”继续调用 `AgentRetry → BatchRetry → Create`,按当前档案创建不同 task ID;Admin 批量重试也保持新任务语义。取消订单、修改既有订单和支付仍禁止。真机重试可能进入创建待付款订单流程,执行前必须再次取得人工授权。
## Agent 状态页手动检查任务
@@ -331,12 +334,16 @@ synchronized_at: 2026-09-03T01:55:28Z
- 自动确认必须保存来源、实际置信度和脱敏限长原因;人工确认完成后同步对应分项及主表总体状态。worker 不得覆盖已由人工改变的映射。
- 纠错不能简单执行 B→C;必须把原 A→B 记录置为 `superseded`,建立 A→C,并只处理原记录冻结的虾皮商品影响集合。
## 创建采购任务时的异步规格匹配(#148)
## 历史:创建采购任务时的异步规格匹配(#148,#215 后仅兼容存量)
- 创建任务继续同步使用已确认映射和唯一确定性 `exact_match`;只有需要外部 AI 时,任务与 `purchase_spec_match_work_item` 在同一事务创建,接口立即返回。活动工作项或 `manual_required` 未处理前,`Next`、`Claim`、`Start` 均拒绝任务。
- AI 结果只固化到采购任务快照,不自动写回虾皮商品长期规格映射。`ai_match` 必须达到设置阈值、严格属于当前候选、原因非空且写入前输入指纹未变化;否则转人工,Agent 不猜测。
- provider 网络、超时或 5xx 按 30 秒、2 分钟、10 分钟退避,最多尝试 3 次;预算内恢复会自动继续,耗尽后转 `manual_required`。Admin 可重新入队或从当前候选人工选择。
- 异步 provider 单次调用最长 60 秒,即 `min(配置超时, 60 秒)`;设置页测试连接仍使用完整配置超时。取消任务会同时终结活动工作项,输入变化会关闭旧工作项并要求重新入队。
- #215 起,新建 SYB 采购任务不再根据 PDD 档案创建 `purchase_spec_match_work_item`,也不在首次派发前调用外部 AI;规格决策统一来自首趟真机探测的当次候选。
- 部署前已存在的匹配工作项继续保留审计、恢复、重试和人工处理能力;其 AI 封闭候选、置信度、输入指纹、退避和最长 60 秒调用边界不变,但不能成为新任务跳过当次探测的依据。
## SYB 采购强制当次规格探测(#215)
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务;第二趟复用 PDD 当前页,不再次打开链接,也不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
- 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。
- 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。
## Agent 手动采集替代商品(#130)
@@ -386,6 +393,13 @@ synchronized_at: 2026-09-03T01:55:28Z
- Agent APK 版本由管理员在设备页抽屉中上传、核对并显式设为当前。服务端从 APK Manifest 读取整数 `versionCode` 和 `versionName`,保存 SHA-256、大小、说明和创建人;APK 位于非公开目录,Admin 与设备下载均需认证。
- Agent 只以整数 `versionCode` 判断更新。启动时静默检查一次,设置页可手动检查、显示下载进度并取消;下载完成必须校验大小和 SHA-256。设备有活动任务时禁止检查、下载和安装;安装始终交给 Android 系统确认,项目不绕过未知来源权限。
## Android 采购规格动作安全执行(#214)
- 规格入口与精确规格选择统一按“重新定位唯一目标 → 执行一次无障碍点击 → 读取新页面验证”执行;不得复用旧无障碍节点,不选择相近规格,也不在多候选时默认点击第一个。
- 无障碍点击后页面完全无变化时,只允许对解析器已确认的安全规格入口或服务端下发且唯一命中的精确规格执行一次中心手势兜底。目标必须可见、启用、边界有效,手势后仍须以规格面板强证据或精确选中证据确认结果。
- 页面发生变化但规格面板强证据不足时,不再继续手势或猜测页面,明确失败并只记录面板类型、候选数量和证据布尔值等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
- 规格已处于精确选中状态时不得重复点击。规格查找只在解析器唯一识别的规格面板容器内有限滚动,每次滚动后重新定位容器与目标;容器缺失、歧义、到边或验证失败均 fail-closed。
- 中心手势兜底不得用于修改/保存地址、创建或提交订单、订单详情入口以及任何支付/付款目标;正式创建订单的一次性不可逆门禁与永久禁止支付规则不变。
## PDD 商品反向关联与继续订单采购(#161)
- 一个 PDD 商品可被多个虾皮商品共用。PDD 详情返回全部关联虾皮商品摘要;订单行由独立统一分页接口读取,不按虾皮商品伪造嵌套分页。
@@ -409,3 +423,11 @@ synchronized_at: 2026-09-03T01:55:28Z
- AI 只在关联蝦皮商品的封闭候选集合中返回原始颜色/尺码,并必须提供达到当前自动确认阈值的置信度和非空理由;集合外值、缺失角色、低置信度、歧义、无结果和输入漂移都不得确认。
- `parse_status` 保留确定性解析器结论;AI 与人工确认分别记录,人工优先级最高。重复同步不得覆盖人工值;完全相同输入保留 AI 值,来源或关联变化会清除旧 AI 确认。
- 同一输入的低置信度或无结果不重复调用 Provider;临时故障至少 60 分钟后重试,最多 3 次。任务不创建采集/采购任务、订单,不执行 Android 动作或付款。
## 设备身份恢复
- 管理员可在设备管理对未停用设备发起“重置设备身份”。此动作不删除设备记录、不改变设备 ID、能力或已绑定的待领取任务;它立即使旧 Token 无效,并只生成一次、有效期 10 分钟的恢复码。
- 恢复码仅显示给发起操作的管理员一次,服务端只保存摘要;不得进入列表、日志、任务记录、Android 持久化或普通接口。手机操作员必须在同一安装实例的 Agent 设置中手动输入。
- 服务端只接受同一 `installId`、未过期且尚未使用的恢复码完成重新注册,成功后签发新 Token 并使恢复码失效。过期、重复使用、installId 不符或停用均明确失败;不能通过清空数据、直接改库或“吊销 Token”恢复原任务归属。
- 自 #223 起,任务创建时把与冻结 SYB 目标对应的已确认商品规格映射保存为不可变的探测指导快照,但仍不得跳过首趟真机探测。探测完成后,每个角色先验证快照映射能否按既有规范化规则唯一对应当次候选,能对应时固化当次候选原文;不能对应时只对该未解决角色执行确定性匹配,仍无结果才调用 AI。已解决角色不重复交给 AI,任一最终值仍必须逐字属于当次候选;历史映射失效、规范化后歧义或角色不符时不得复用。候选完整但无法决策时提示“已采集到当前规格,但未能确定颜色或尺码映射”,不再误报候选不存在。
+28 -2
View File
@@ -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: 0bb8d63fe2a3c6b1021bfa18800f5062de726868
synchronized_at: 2026-08-31T16:06:55Z
wiki_revision: 835494c4a63a1494601658561fde1ab76657be3d
synchronized_at: 2026-09-05T07:17:05Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -24,6 +24,32 @@ T01 已建立可执行的三端骨架。建议从仓库根目录运行统一脚
- 不得仅为设置编码重复启动一层 PowerShell;嵌套进程会增加启动时间、转义复杂度和错误定位成本。
- 代码发现优先使用项目配置的代码图工具;检索字符串、配置和非代码文件,或图工具不足时使用 `rg`。
### PowerShell 语法与外部命令
- Windows 命令不得默认套用 Bash 语法;复杂正则优先先赋给变量或使用 `rg -e`,避免在多层引号中继续嵌套。
- 多行 Python 或 JSON 正文使用单引号 PowerShell here-string,避免 `$()`、反引号和变量被 PowerShell 提前展开:
```powershell
$script = @'
print("保持原文")
'@
$script | python -
```
- `foreach`、`if` 等语句块应保留在同一个 PowerShell 解析上下文中;需要收集表达式结果时使用数组表达式:
```powershell
$items = @(foreach ($path in $paths) {
if (Test-Path -LiteralPath $path) { Get-Item -LiteralPath $path }
})
```
- `rg` 使用真实目录配合 `-g/--glob`,不要把 Bash 风格通配路径作为目录参数:
```powershell
rg -n -g '*.md' 'sync --check' docs
```
### 文件编码与控制台输出
文件解码和控制台输出是两个边界。读取 UTF-8 文本时,在命令支持的情况下显式指定字面路径与编码:
+2 -2
View File
@@ -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: b7d5520da954c544248e979c4ababb86d2784a43
synchronized_at: 2026-08-27T09:38:36Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:12Z
<!-- gitea-wiki-mirror:end -->
# 常见修改指南
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: a8d4790811aa825c78830cb9c71c6b21723e4584
synchronized_at: 2026-08-28T03:35:05Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:16Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
+2 -2
View File
@@ -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: 0d9924b4566e567edf167ba678fedde28a41cfd3
synchronized_at: 2026-08-28T02:27:12Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:20Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览与当前 MVP
+52 -30
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: c214791a0b055e5b56c0ad05e73829a1f141dd46
synchronized_at: 2026-09-01T08:03:51Z
wiki_revision: 0c6d95d07adc7ec24982b195e53059151da4f451
synchronized_at: 2026-09-05T08:47:13Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -405,7 +405,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
- `executionMode` 只能是 `rehearsal` 或 `live`,创建后不可修改。
- 正式 `live` 任务必须引用一条 `syb_product`;无副作用的 `rehearsal` 可以不引用 SYB。
- 创建时固化 SYB、蝦皮订单号、虾皮商品、PDD 商品、目标规格、映射规格、数量、价格区间、币种、URL、`goods_id`、规则和可选 PDD 账号引用。正式任务的 `shopeeOrderNoSnapshot` 来自创建时的 `syb_product.order_code`;同一订单号可对应多条任务,之后来源商品修改不会改写任务快照。
- 创建时固化 SYB、蝦皮订单号、虾皮商品、PDD 商品、目标规格、数量、价格区间、币种、URL、`goods_id`、规则和可选 PDD 账号引用。新建 SYB 任务的执行映射初始为空,首趟当次 PDD 规格探测决策成功后才固化 `mappedColor` / `mappedSize`;正式任务的 `shopeeOrderNoSnapshot` 来自创建时的 `syb_product.order_code`,之后来源商品修改不会改写任务快照。
- Android 无法可靠读取当前 PDD 账号,因此 `pddAccountId` 和 `pddAccountRefSnapshot` 均可空。已知账号时参与账号级串行,未知时不阻止任务。
- 地址后缀由任务 ID 唯一确定为 `_cg{taskId}`;#34 创建任务时必须在同一事务内回写快照。
- 一个任务最多对应一个 PDD 订单;重新采购必须新建任务,旧任务和旧订单保留。
@@ -415,7 +415,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
| 状态 | 含义 | 是否占用 SYB 活动槽 |
|---|---|---|
| `pending` | 待执行 | 是 |
| `spec_probe_pending` | 第一趟探测结束,待服务端固化规格并重新派发 | 是 |
| `spec_probe_pending` | 第一趟探测结束,服务端规格匹配中;当前设备保留同任务连续流程且不得领取其他任务 | 是 |
| `running` | Agent 执行中 | 是 |
| `rehearsal_completed` | 演练安全结束,未改地址、未创建订单 | 否 |
| `order_submit_started` | 不可逆标记已落库,只能核单,禁止再次点击 | 是 |
@@ -493,6 +493,16 @@ POST /api/agent/v1/tasks/{taskId}/fail
演练规则必须包含 `purchase.rehearsal.v1`,并且不能包含 `updateShippingAddress`、`createOrder`、`readOrderResult`。正式规则必须包含 `purchase.live.v1`;改地址和创建订单还分别要求 `purchase.address-update.v1`、`purchase.order-create.v1`。`probeSpecs` 要求 `purchase.spec-probe.v1`。任意模式下,尚未实现的 `pay`、名称包含 `payment` 的动作以及未知动作一律拒绝;服务端不下发任意脚本。
### Android 规格动作执行边界(#214)
本工单不改变采购规则 schema、Agent HTTP 字段或能力标识,只收紧 Android 对 `openSpecPanel` 与 `selectSpec` 的执行语义:
- 每次操作从最新 `rootInActiveWindow` 重新定位;规格入口必须是解析器已接受的唯一安全候选,规格值必须逐字等于任务固化的服务端映射值且在当前维度唯一、可用。
- 动作前先检查后置条件;规格已精确选中时直接成功,不重复点击。否则最多分发一次 `ACTION_CLICK`,再从新快照验证规格面板强证据或精确选中证据。
- `ACTION_CLICK` 后页面结构完全无变化时,Android 可以重新定位同一唯一目标,并在目标可见、启用、中心点位于屏幕内且边界有效时执行一次中心手势。手势后仍未取得后置证据时明确失败,不继续点击。
- 点击后页面已变化但面板分类仍为未知时返回 `PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED`;两种点击后页面均无变化时返回 `PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT`。诊断只携带面板类型、滚动容器数、标题数、选项数及证据布尔值,不携带节点文字或完整控件树。
- 有界查找规格时只使用当前解析结果中的唯一 `specPanelContainer`;每次滚动前重新定位该容器,优先使用节点滚动动作,失败后才在容器边界内使用手势。缺少唯一容器时停止,不回退到全页面最大滚动区域。
- 受控中心手势接口固定拒绝地址、保存地址、创建/提交/确认订单、确认购买和支付/付款语义;`updateShippingAddress`、`createOrder`、`readOrderResult` 不使用该兜底。创建订单的一次性不可逆状态机与禁止支付契约不变。
### 管理端接口
| 方法 | 路径 | 幂等键 / 说明 |
@@ -501,7 +511,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
| `GET` | `/api/admin/v1/purchase-tasks/{taskId}` | 只读任务详情与 attempt 历史;不返回规则原文、Token、凭据、完整地址、控件树或截图 |
| `POST` | `/api/admin/v1/purchase-tasks` | `requestId`;单条创建 |
| `POST` | `/api/admin/v1/purchase-tasks/stock` | 创建备货采购;`requestId`、`executionMode`、`pddProductId`、可选 `deviceId` / `pddAccountId`、`color`、可选 `size`、`quantity`、`minUnitPriceCent`、`maxUnitPriceCent`。服务端使用内置规则并从所选颜色归档派生参考价;相同 `requestId` 幂等返回原任务 |
| `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回采购是否可创建、价格区间、原因和下一步,并独立返回 PDD 采集资格 `collectionEligible` / `collectionDisabledReason` 与规格匹配资格 `aiMatchEligible` / `aiMatchDisabledReason`,不创建任务。只有目标颜色和尺码已保存为确认映射,且共同命中最近一次成功或部分成功采集中的完整可售 SKU 组合时,`eligible` 才为 `true`;只在内存中得到的确定性结果不算采购就绪 |
| `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回采购是否可创建、价格区间、原因和下一步,并独立返回 PDD 采集资格 `collectionEligible` / `collectionDisabledReason` 与长期规格匹配资格 `aiMatchEligible` / `aiMatchDisabledReason`,不创建任务且不调用 AI Provider。自 #215 起,长期映射缺失或失效不再单独使 `eligible=false`;新任务会在首趟当次 PDD 页面探测中决定执行规格 |
| `POST` | `/api/admin/v1/purchase-tasks/batch-spec-match` | `sybProductIds`(1~100);仅处理服务端预检 `aiMatchEligible=true` 的明细,其他明细按具体禁用原因返回 `skipped`,并逐条返回 `auto_confirmed` / `pending` / `failed` / `skipped`。资格要求 SYB 采购规格解析成功、蝦皮与 PDD 关联完整、PDD 当前规格可用,且最近一次成功或部分成功采集存在完整可售 SKU 组合证据;未人工确认的 `parse_status=uncertain` 不进入自动确认,`manuallyConfirmed=true` 与 `parse_status=success` 同等视为可信规格。唯一确定性 `exact_match` 不调用 Provider,直接通过独立路径保存为 `confirmed`;其余结果只有 `ai_match` 置信度达到 `autoConfirmMinConfidence`、理由非空、返回值属于当前候选且颜色+尺码命中同一个可售 SKU 组合时才保存为 `confirmed`。低置信度、无效组合和 Provider 异常不改写现有映射 |
| `POST` | `/api/admin/v1/purchase-tasks/batch` | 批次 `requestId`、`sybProductIds`(1~100)和可选 `deviceId`;每条派生稳定幂等键并独立创建,部分失败不回滚成功项 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/authorize-repurchase` | 一次性授权;创建新任务后自动消耗 |
@@ -519,17 +529,17 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列
| 方法 | 路径 | 说明 |
|---|---|---|
| `GET` | `/api/agent/v1/purchase-tasks/next` | 返回与设备能力兼容的指定任务或空闲任务 |
| `GET` | `/api/agent/v1/purchase-tasks/next` | 优先返回当前设备的运行任务;存在 `spec_probe_pending` 时返回同一等待任务以阻止其他任务插队,否则返回能力兼容的指定任务或空闲任务 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/claim` | `requestId` 原子领取,并建立设备/可选账号租约 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/start` | 创建不可变 `taskAttemptId` |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务永远拒绝 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务和 `spec_probe` attempt 永远拒绝 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 |
创建阶段需要外部 AI 的任务会先以 `pending` 与持久匹配工作项同事务创建并立即返回 Admin。工作项处于 `pending`、`running`、`retry_wait` 或 `manual_required` 时,`next` 不返回该任务,直接调用 `claim` 或 `start` 也会返回状态冲突。匹配完成后 Agent 仍只接收服务端固化的精确 PDD 原始标签;Android 接口和请求体不新增 AI、候选或人工决策字段。
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。第二阶段直接复用首趟保留的 PDD 页面,不再次打开浏览器链接,也不以标题、goodsId 或页面指纹做严格同页校验;仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。慢路径第一趟提交规格后释放设备与已知账号租约,任务进入 `spec_probe_pending`;服务端先使用已确认人工映射,否则对实时/档案可选规格做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。第二趟只会收到服务端已固化的精确 PDD 原始标签;Agent 只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放数据库租约和已知账号运行守卫并进入 `spec_probe_pending`,但服务端调度与 Agent 必须把当前设备保留给同一采购流程:`next` 返回该等待任务,Agent 只轮询等待,不领取其他采购或采集任务。服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 复用首趟仍打开的页面,只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
任务 payload 新增必传布尔字段 `specResolutionAllowed`,它是 Android 是否可以把一次 `PURCHASE_SPEC_TARGET_NOT_VISIBLE` 转为 `spec_probe_completed` 的唯一资格事实。服务端仅在 `taskType=syb_order`、规则声明 `purchase.spec-probe.v1`、没有固化 `SpecDecisionRequestID` 且规格来源允许既有慢路径时返回 `true`;`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。普通就地重试保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格。Android 不得根据映射是否非空、执行 phase、错误文字或本地判断扩大资格。
任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。普通 Agent 重试使用就地 `/reset`,保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;映射不完整时必须拒绝,不能进入正式采购阶段。只有替代商品“继续采购”或 Admin 批量重试创建的新任务才按 #215 重新取得一次探测资格。Android 不得根据映射是否非空、错误文字或本地判断扩大资格。
Android 规格失败使用五个稳定阶段:`PURCHASE_SPEC_TARGET_NOT_VISIBLE`、`PURCHASE_SPEC_TARGET_AMBIGUOUS`、`PURCHASE_SPEC_SAFE_TARGET_MISSING`、`PURCHASE_SPEC_CLICK_FAILED` 和 `PURCHASE_SPEC_SELECTION_UNCONFIRMED`。`PURCHASE_SPEC_CLICK_FAILED` 的 `errorMessage` 只允许稳定子原因 `root_unavailable`、`target_stale`、`no_clickable_ancestor`、`action_click_false` 或 `unknown`;其他阶段的消息不得包含规格原文、坐标、控件树或截图。只有 `PURCHASE_SPEC_TARGET_NOT_VISIBLE && specResolutionAllowed=true` 可以提交规格探测,其他四态直接提交真实失败,服务端原样保留稳定阶段/子原因。旧 Agent 在资格已用尽后再次提交 `spec_probe_completed` 时,服务端以 `PURCHASE_SPEC_REPROBE_REJECTED` fail-closed,释放租约并保留第一次规格决策,不再冒充新的选择根因或再次派发。无匹配、候选不完整、歧义或 Provider 异常同样使任务失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。
@@ -607,12 +617,12 @@ Content-Type: application/json
- 响应返回 `taskId`、`attemptNumber`、`status` 和可选的 `replayed`,不返回规则快照、URL、Token、控件树或截图。
- 设备离线、任务非终态、设备忙、规则不可用或同商品存在活动任务时返回明确冲突,不支持离线排队。
## Agent 受控采购重试(#95、#157)
## Agent 受控采购重试(#95、#157、#217、#225)
普通失败任务的“重试采购”改为就地重跑:
替代商品匹配完成后的“继续采购”调用新任务接口;普通失败任务的“重试采购”使用下方同任务重置接口:
```http
POST /api/agent/v1/purchase-tasks/{taskId}/reset
POST /api/agent/v1/purchase-tasks/{taskId}/retry
Authorization: Bearer <device-token>
Content-Type: application/json
@@ -624,37 +634,35 @@ Content-Type: application/json
```json
{
"data": {
"taskId": 12,
"taskNo": "CG-12",
"attemptNumber": 2,
"status": "pending",
"sourceTaskId": 12,
"sourceTaskNo": "CG-12",
"taskId": 13,
"taskNo": "CG-13",
"replayed": false
}
}
```
- 原任务必须属于当前 Device Token、在最近 30 天内、状态为 `failed`,且是分配给该设备的正式 SYB 采购任务;跨设备或超期按任务不存在处理。
- 任务不得存在 `irreversibleAt`、`orderSubmitRequestId`、PDD 订单号或下单时间。已有任何不可逆证据时返回 `PURCHASE_RETRY_UNSAFE`,提示走“授权重新采购”,不得恢复为待执行。
- 服务端在同一事务锁定任务和设备,确认同一 SYB 商品没有更新任务、设备在线且空闲,然后读取当前服务端采购规则,重新校验 schema、动作安全边界与设备能力。
- 成功时复用原 `purchase_task.id`,只刷新 `ruleSnapshot`、`ruleType`、`ruleSchemaVersion`、`requiredCapabilities`,清除错误、租约和运行守卫并恢复 `pending`。商品、Target、Mapped、价格保护、数量、地址后缀及其他业务快照逐字段保持不变。
- 每次受理创建该任务的新 `pending` attempt 并记录规则哈希;Start 复用该 attempt 转为 `running`,不会重复创建执行记录。相同 `requestId` 重放返回相同 attempt 且 `replayed=true`;不同请求可在任务再次失败后继续重试,不限制次数。
- 采购详情新增 `attemptCount`、可选 `lastFailureCode` 和 `lastFailureMessage`,供 Agent 显示已尝试次数与上次失败原因;不返回规则快照、Token、地址、控件树或截图。
- Android 仍只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认文案必须说明任务号不变、使用最新规则重跑、可能产生待付款订单且系统不会支付。
- 规则无效、设备离线/忙、能力不匹配、任务状态变化或同一 SYB 商品已有更新任务时,服务端明确拒绝且不得部分修改任务。
- 来源任务必须属于当前 Device Token、在最近 30 天内、状态为 `failed`,且是分配给该设备的正式 SYB 采购任务;跨设备或超期按任务不存在处理。
- 来源任务不得存在 `irreversibleAt`、`orderSubmitRequestId`、PDD 订单号或下单时间。已有任何不可逆证据时返回 `PURCHASE_RETRY_UNSAFE`,提示走“授权重新采购”,不得创建新任务。
- `AgentRetry → BatchRetry → Create` 保留来源失败任务并创建不同 `purchase_task.id` 的新任务;新任务重新读取当前 SYB、虾皮/PDD 档案、当前采购规则、价格保护和设备能力,重新生成地址后缀,不继承来源任务的旧规格决策。
- 新 SYB 任务按 #215 固定从 `spec_probe` 开始。相同 `requestId` 重放返回同一新任务且 `replayed=true`;不同 requestId 再次请求受同一 SYB 商品最新任务和设备并发门禁约束。
- 本新任务接口只供替代商品“继续采购”;确认和成功反馈必须说明保留来源任务、按当前替代商品档案创建新任务、可能产生待付款订单且系统不会支付。
- 规则无效、当前档案或价格不合格、设备离线/忙、能力不匹配、任务状态变化或同一 SYB 商品已有更新任务时,服务端明确拒绝且不得部分创建。
- Admin 批量重试继续使用相同的新任务语义;替代商品“继续采购”仍在 AgentRetry 前额外验证替换分项与继续采购资格。
既有新建任务接口保留原语义:
普通失败任务的“重试采购”调用同任务重置接口:
```http
POST /api/agent/v1/purchase-tasks/{taskId}/retry
POST /api/agent/v1/purchase-tasks/{taskId}/reset
Authorization: Bearer <device-token>
Content-Type: application/json
{"requestId":"<uuid>"}
```
- `/retry` 的 `AgentRetry → BatchRetry → Create` 行为不变:保留来源失败任务并创建新任务,返回 `sourceTaskId`、`sourceTaskNo`、新 `taskId`、新 `taskNo` 和 `replayed`。
- Android 普通“重试采购”不再调用 `/retry`;只有 #132 替代商品规格匹配完成后的“继续采购”继续调用它。Admin 批量重试行为也不变。
- “继续采购”会重新解析替代商品、规格映射与价格并生成新地址后缀;这与普通失败任务保持快照的就地重跑不可互换。
- `/reset` 只允许当前设备的安全失败任务且不得存在不可逆证据或更新任务;它复用原 `purchase_task.id`、新增 attempt、刷新当前有效规则,并保持商品、目标/执行规格、数量、价格、地址和规格决策等业务快照不变。
- 目标颜色存在但映射颜色为空,或目标尺码存在但映射尺码为空时,必须返回 `PURCHASE_SPEC_MAPPING_REQUIRED`,不得创建 `purchase` attempt 或下发正式采购 payload。相同 `requestId` 重放返回同一 task ID 和 attempt,不重复递增;Android 必须明确提示复用原任务和新的 attempt 序号。
- 两个入口都不执行支付。真机调用可能创建待付款订单,必须先取得人工授权。
## Agent 任务记录范围与同步(#99)
@@ -845,3 +853,17 @@ file=<JPEG 二进制>
| `GET` | `/api/agent/v1/app/releases/{releaseId}/download` | Device Token;私有 APK 下载 |
Agent 只比较整数 `versionCode`。设备有活动任务时禁止检查、下载和安装;下载到应用私有缓存并校验响应大小与 SHA-256,失败立即删除。安装使用 FileProvider 和 Android 系统安装确认页;未知来源权限必须由用户在系统设置授权,不静默安装。
## 设备身份恢复(#201)
```http
POST /api/admin/v1/devices/{deviceId}/identity-reset
POST /api/agent/v1/register
X-GoAuto-Device-Recovery-Code: <one-time-code>
```
仅管理员可以对未停用的既有设备发起身份重置。服务端立即使旧 Device Token 无效,并生成 10 分钟内仅能使用一次的恢复码;恢复码只在该管理员操作的响应中返回一次,服务端仅保存不可逆摘要,管理端设备列表、日志、任务接口和 Android 本地持久化均不得保存或返回原文。管理员将恢复码经受控人工渠道输入同一安装实例的 Agent 设置页。
Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口。服务端必须同时校验同一 `installId`、未停用状态、恢复码摘要、未过期和未使用;成功后使用原 `deviceId` 写入新 Token 摘要并返回一次新 Token,清除恢复码摘要和有效期。旧 Token 与恢复码都立即失效,已分配的 pending 采集或采购任务保持原 `deviceId`,不创建替代设备记录。缺少或错误恢复码仍为 `DEVICE_INSTALL_ID_CONFLICT`;过期码为 `DEVICE_RECOVERY_EXPIRED`;停用设备为 `DEVICE_DISABLED`。
自 #223 起,新建 SYB 任务在保持 `mappedColor` / `mappedSize` 为空和首趟 `spec_probe` 不变的同时,把创建时与目标规格对应的 confirmed 商品映射冻结为仅供服务端决策的指导快照。服务端收到当次候选后按角色验证该快照:只有规范化后唯一对应当次候选时才复用,并固化当次候选原文;否则该角色继续执行确定性匹配,仍未解决才把该角色及其封闭候选交给 AI。已解决角色不得重复发送给 AI,最终颜色和尺码仍须逐字属于各自当次候选。任务决策快照通过 `roleSources` 记录每个角色的 `manual_mapping` / `exact_match` / `ai_match` 来源;任务级 `specSource` 使用现有枚举汇总,不新增 Agent 决策权限。
+2 -2
View File
@@ -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: 0eed28dd5dc629ce66071833d5fefbdf24d567d3
synchronized_at: 2026-08-24T02:35:58Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:28Z
<!-- gitea-wiki-mirror:end -->
# 当前 MVP 交付工单索引
+2 -2
View File
@@ -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: 5b74a3b915cda6307cd59087bf9ce295f4c5e806
synchronized_at: 2026-08-21T08:08:57Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:32Z
<!-- 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: 1fe9ed083c4fc565a50b4186571233d1dfab58a7
synchronized_at: 2026-08-17T03:28:38Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:37Z
<!-- gitea-wiki-mirror:end -->
# PDD 商品详情采集规则迁移分析
+20 -7
View File
@@ -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: f596931468caf524cbd9d90b3c299b32a8cf8228
synchronized_at: 2026-08-28T02:29:07Z
wiki_revision: ad9adc22e69e48c9a8fd87d7121066eecca55fd6
synchronized_at: 2026-09-04T11:30:50Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
@@ -393,19 +393,32 @@ POST /am/stock/detail/listByStock?hist=0
顺运宝给的是**商品级 ID + 规格原文**,没有規格 ID。
### 6.3 `productSpec` 的格式和蝦皮报表完全一致
### 6.3 `productSpec` 的角色顺序不是固定的
```text
蝦皮目录 `spec_raw` 黑色,M【建議40-50公斤】
顺运宝 productSpec 白色,L【建議50-60公斤】
黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】
均碼,黑色
```
`[必须]` 规格身份统一复用 `spec.SpecKey()`:只折叠空白,不猜颜色、尺码或建议。
第三方目录脚本提交 `spec_raw` 和明确的解析结果;顺运宝匹配不到时交给人工确认。
2026-09-04 的真实数据确认:`productSpec` 至少存在 `颜色,尺码` 与
`尺码,颜色` 两种顺序,不能再固定把逗号前当颜色、逗号后当尺码。
`[必须]` 先按最后一个 ASCII 逗号拆成两段并剥离 `【...】` 备注。只有两侧
恰好一侧带有明确尺码信号(如均码、字母尺码或明确尺码单位)时,才把该侧判为
尺码、另一侧判为颜色。两侧都像尺码时标记存疑;不得使用模糊颜色词库、AI 或
跨维度候选猜测角色。两侧都没有明确尺码信号时,暂按既有 `颜色,尺码` 契约兼容,
并继续执行原有复杂分隔符和空值校验。
`[必须]` 修正规格角色时保留原始 `raw_json`,不得覆盖人工确认值。已错误写入
虾皮档案的导入规格只能在确认无其他 SYB 明细引用且没有规格映射时移除;人工规格、
映射及历史采购任务保持不变。
`[必须]` 规格身份继续复用 `spec.SpecKey()`:只折叠空白,不改写用于 PDD
精确点击的原始候选标签。第三方目录脚本提交 `spec_raw` 和明确解析结果;角色或
规格比对不明确时保持存疑,不得跨颜色/尺码猜测。
### 6.4 由此推导出的匹配路径
```text
productId ──→ shopee_products.goods_id 商品级,直接相等
productSpec ──→ SpecKey() ──→ 稳定规格身份键
+30 -2
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: 80ae5c8cea87c624fff63ce3e302aa7bf204811e
synchronized_at: 2026-08-31T16:07:03Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:08Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -39,6 +39,34 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true
发布、重启、设置当前 Agent 版本和真机安装都属于高风险操作,必须有对应工单和人工确认。不得在部署过程中执行付款、创建订单或清理生产数据。
### API 长请求超时约束
Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允许 3~600 秒,复合的一键匹配共享 600 秒总预算,Admin 客户端等待 610 秒。因此服务端 `settings.application.writetimeout` 必须至少为 620 秒,受版本管理的标准配置固定为 `620`;启动时低于该值必须明确失败,禁止以 2 秒等短写超时运行,否则连接会在领域错误返回前被截断并表现为代理 502。反向代理的读/发送超时也必须大于服务端窗口。
Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。
## 回滚
服务异常时把 `/home/goauto/current` 切回上一已验证发布目录并重启 `goauto.service`,随后复核服务状态和 Agent 接口。不要删除当前或历史 APK/发布目录来代替回滚;数据库变化如需回退必须单独评估。
## 蝦皮规格自动匹配任务运维(#195)
- 迁移 `1788290000000_shopee_spec_auto_match.go` 创建运行与工作状态表,并幂等写入 `sys_job.invoke_target=GoAutoShopeeSpecAutoMatch`。首次部署状态为关闭,迁移重跑不会覆盖管理员后来修改的 Cron、参数或启停状态。
- 默认 Cron 为 `0 15 * * * *`(每小时第 15 分钟),默认 `batchLimit` 为 20,合法范围 1~100。上线后先保持关闭,在 Admin 蝦皮商品列表手动执行小批次并核对运行摘要和映射,再由管理员在定时任务页明确启用。
- 定时和手动触发共享数据库唯一活动槽。看到“已有自动匹配正在运行”属于正常互斥结果;运行租约超过 30 分钟会在下次触发时安全释放并把旧运行标记失败。处理每个商品前会续租。
- Provider 临时失败的商品至少 60 分钟后重试,最多 3 次;低置信度或无结果在输入指纹不变时不会重复调用。排错优先查看 `shopee_spec_auto_match_run` 的结构化计数与脱敏错误,以及 `shopee_spec_auto_match_work_item` 的状态、尝试次数、下次尝试时间和租约;不得输出或复制 API Key、Provider 原始响应、商品原始 JSON 或个人数据。
- 紧急回退只需关闭系统任务并停止使用 Admin 手动入口;保留运行记录和既有已确认映射用于审计,不删除数据,也不回滚 #194。
## SYB 异常规格 AI 定时任务(#198)
- 追加迁移 `1788354164329_syb_spec_ai_parse.go` 创建 AI 确认字段、`syb_spec_ai_parse_run`、`syb_spec_ai_parse_work_item`,并幂等写入 `sys_job.invoke_target=GoAutoSYBSpecAIParse`。
- 系统任务默认关闭,默认 Cron 为每小时第 5 分钟、参数 `{"batchLimit":20}`;迁移重跑不得覆盖管理员后续修改的 Cron、参数或启停状态。建议先运行该任务,再由默认第 15 分钟的 #195 完成蝦皮到 PDD 的规格匹配。
- 多实例通过可空唯一活动槽和租约保证全局单批运行;逐行工作项按输入指纹去重。Provider 临时失败至少 60 分钟后重试且最多 3 次,低置信度/无结果在输入不变时不重复调用。
- 上线顺序为:备份数据库、执行追加迁移、发布服务并保持任务关闭、核验表/字段/任务种子,再由管理员决定是否启用。排错只查看运行计数、工作状态和脱敏错误,不输出 API Key、Provider 原始响应、完整 RawJSON 或订单数据。
## 定时任务执行历史运维(#199)
- 发布 #199 时先备份数据库,再执行追加迁移 `1788357000000_sys_job_execution_log.go`,随后发布服务与 Web。迁移创建 `sys_job_execution_log`、登记 `GET /api/v1/sysjob/:id/execution-logs`,并只为原本绑定隐藏菜单 `JobLog` 的角色写入精确读取权限;菜单缺失时迁移明确失败,不扩大角色授权。
- 服务启动会将上次进程遗留的 `running` 执行记录标记为 `interrupted`。该恢复依赖当前线上每个数据库只运行一个调度器实例;扩展为多调度器前必须另建工单引入实例租约,不能直接复用此判断。
- 排错从 Admin 定时任务页单选任务后进入“日志”,按状态和开始时间查询。记录只含稳定错误码和脱敏摘要;需要定位细节时查看受控服务日志,不得把任务参数、Provider 配置/响应、密钥或业务原始数据复制进执行历史。
- 当前没有执行历史删除接口和自动保留策略;删除定时任务不删除历史。数据库容量治理需要另建工单评估。#198 的 `GoAutoSYBSpecAIParse` 在 #199 发布和迁移后仍保持关闭,启用必须由管理员另行确认。
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Home
wiki_revision: d5a46210da9dffa27247d80b20ab31b40790e48a
synchronized_at: 2026-08-31T16:06:14Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:29:34Z
<!-- gitea-wiki-mirror:end -->
# GoAuto 文档中心
+2 -2
View File
@@ -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: 389f09d292b0aa408f26f1967e33eec82d37cedf
synchronized_at: 2026-08-27T09:39:50Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:41Z
<!-- gitea-wiki-mirror:end -->
# 部署文档模板
+2 -2
View File
@@ -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: 53927b3b69fa26943f323f9be53153f80ed54530
synchronized_at: 2026-08-24T08:23:07Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:46Z
<!-- gitea-wiki-mirror:end -->
> 本模板只用于用户明确要求的专项历史快照或读取既有归档,不属于标准任务闭环。单次任务的唯一事实来源是 Gitea 工单;不要为了完成普通任务创建本页面,也不要自动导出到 `docs/task/`。
+6 -5
View File
@@ -35,11 +35,12 @@ type CandidateSnapshot struct {
}
type DecisionSnapshot struct {
Source string `json:"source"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Candidates CandidateSnapshot `json:"candidates"`
Matched struct {
Source string `json:"source"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Candidates CandidateSnapshot `json:"candidates"`
RoleSources map[string]string `json:"roleSources,omitempty"`
Matched struct {
Color string `json:"color,omitempty"`
Size string `json:"size,omitempty"`
} `json:"matched"`
+1 -1
View File
@@ -18,7 +18,7 @@ func TestParseBuiltAgentAPKWhenAvailable(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if metadata.VersionCode != 49 || metadata.VersionName != "0.9.36" {
if metadata.VersionCode != 54 || metadata.VersionName != "0.9.41" {
t.Fatalf("metadata=%+v", metadata)
}
}
+13 -6
View File
@@ -2,7 +2,6 @@ package purchase
import (
"context"
"strings"
"testing"
"time"
@@ -72,6 +71,11 @@ func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) {
setCollectedPDDPrice(t, db, f.pdd.ID)
service := testService(db)
failed := failedLiveTask(t, db, service, f)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
"target_color": "当前黑色", "target_size": "均碼",
}).Error; err != nil {
t.Fatal(err)
}
history, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token)
if err != nil || !history.Task.Retryable || history.Task.RetryDisabledReason != "" {
@@ -93,6 +97,9 @@ func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) {
if oldTask.Status != models.PurchaseTaskStatusFailed || newTask.Status != models.PurchaseTaskStatusPending || newTask.DeviceID == nil || *newTask.DeviceID != f.device.ID || newTask.AddressSuffix == oldTask.AddressSuffix {
t.Fatalf("retry did not preserve old task or fix new task to device: old=%+v new=%+v", oldTask, newTask)
}
if newTask.TargetColorSnapshot != "当前黑色" || newTask.TargetSizeSnapshot != "均碼" || newTask.SpecSource != "unresolved" || purchaseAttemptPhase(newTask) != models.PurchaseAttemptPhaseSpecProbe {
t.Fatalf("retry did not use current SYB targets and a fresh probe: %+v", newTask)
}
replay, err := service.AgentRetry(context.Background(), failed.ID, request, f.token)
if err != nil || !replay.Replayed || replay.TaskID != first.TaskID {
@@ -158,7 +165,7 @@ func TestMatchedReplacementExposesContinueAndReusesAgentRetry(t *testing.T) {
}
}
func TestMatchedReplacementRejectsClearedMappingWithoutCallingAI(t *testing.T) {
func TestMatchedReplacementAllowsRetryWithoutPersistedMappingAndDoesNotCallAI(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f.pdd.ID)
@@ -172,11 +179,11 @@ func TestMatchedReplacementRejectsClearedMappingWithoutCallingAI(t *testing.T) {
service.Matcher = matcher
detail, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token)
if err != nil || detail.ContinuePurchaseEligible || !strings.Contains(detail.ContinuePurchaseDisabledReason, "规格匹配已失效") || matcher.calls != 0 {
if err != nil || !detail.ContinuePurchaseEligible || detail.ContinuePurchaseDisabledReason != "" || matcher.calls != 0 {
t.Fatalf("cleared mapping qualification mismatch: detail=%+v calls=%d error=%v", detail, matcher.calls, err)
}
_, err = service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token)
if code(err) != CodeMappingRequired || matcher.calls != 0 {
t.Fatalf("cleared mapping reached AI or retry: code=%s calls=%d error=%v", code(err), matcher.calls, err)
created, err := service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || created.TaskID == 0 || matcher.calls != 0 {
t.Fatalf("cleared mapping retry did not create probe task: result=%+v calls=%d error=%v", created, matcher.calls, err)
}
}
+5 -70
View File
@@ -105,8 +105,7 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba
items := make([]BatchPreviewItem, 0, len(ids))
eligible := 0
for _, id := range ids {
item := s.previewFromDataset(ctx, id, dataset, false, guard)
item.enforcePersistedMatch(id, dataset)
item := s.previewFromDataset(id, dataset, guard)
item.applyCollectionEligibility(id, dataset)
qualification := aiMatchQualificationForDataset(id, dataset)
item.AIMatchEligible = qualification.Eligible
@@ -331,22 +330,6 @@ func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64, rul
return ensureCapabilities(record, purchasecontract.RequiredCapabilities(rule))
}
func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem {
_, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
if ruleErr != nil {
item := BatchPreviewItem{SYBProductID: id}
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(ruleErr)
return item
}
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
if err != nil {
item := BatchPreviewItem{SYBProductID: id}
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
return item
}
return s.previewFromDataset(ctx, id, dataset, true, purchasecontract.EffectivePriceGuard(currentRule))
}
func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard purchasecontract.PriceLimits) BatchPreviewItem {
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
if err != nil {
@@ -354,8 +337,7 @@ func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
return item
}
item := s.previewFromDataset(ctx, id, dataset, false, guard)
item.enforcePersistedMatch(id, dataset)
item := s.previewFromDataset(id, dataset, guard)
return item
}
@@ -379,7 +361,7 @@ func taskPointerValue(value *uint64) uint64 {
return *value
}
func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool, guard purchasecontract.PriceLimits) BatchPreviewItem {
func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, guard purchasecontract.PriceLimits) BatchPreviewItem {
item := BatchPreviewItem{SYBProductID: id}
syb, found := dataset.sybByID[id]
if !found {
@@ -423,26 +405,15 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
}
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
item.MappedColor, item.MappedSize = mappedColor, mappedSize
candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
candidates, _ := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
// #190:失效的已保存映射不再拦截,降级为 unresolved,与创建路径保持一致。
if source != "unresolved" && !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
mappedColor, mappedSize, source = "", "", "unresolved"
item.MappedColor, item.MappedSize = "", ""
}
if source == "unresolved" {
if !archiveUsable {
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "关联的 PDD 商品没有完整可选规格", "open_pdd"
return item
}
request := aimatching.MatchRequest{TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
if allowAI {
matched, matchErr := s.matcher().Resolve(ctx, request)
if matchErr != nil {
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(purchaseMatchError(matchErr))
return item
}
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
} else if matched, ok := aimatching.DeterministicMatch(request); ok {
if matched, ok := aimatching.DeterministicMatch(request); ok {
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
}
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
@@ -476,42 +447,6 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
return item
}
// enforcePersistedMatch is the #188 gate for the SYB list and batch-create
// workflow. A transient deterministic result is not purchase readiness: the
// color/size mapping must already be confirmed on the Shopee product and must
// still identify one complete, available SKU combination when collection
// evidence exists. Manually entered or externally imported PDD products may
// legitimately have no such collection record.
func (item *BatchPreviewItem) enforcePersistedMatch(id uint64, dataset batchPreviewDataset) {
if item.ReasonCode != "" && item.ReasonCode != CodeMappingRequired {
return
}
syb, ok := dataset.sybByID[id]
if !ok || syb.ShopeeProductID == nil {
return
}
shopee, ok := dataset.shopeeByID[*syb.ShopeeProductID]
if !ok || shopee.PDDProductID == nil {
return
}
pdd, ok := dataset.pddByID[*shopee.PDDProductID]
if !ok || pdd.Status != "active" {
return
}
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
item.MappedColor, item.MappedSize = mappedColor, mappedSize
if source == "unresolved" {
item.Eligible = false
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "规格尚未匹配并保存,请先完成 AI 匹配或人工确认", "open_mapping"
return
}
combinations := dataset.skuCombinationsByPDD[pdd.ID]
if len(combinations) > 0 && !validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
item.Eligible = false
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "已保存规格映射不属于当前可售的 PDD 规格组合,请重新匹配", "open_mapping"
}
}
func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceLimits) (reference, minPrice, maxPrice int64, err error) {
var dimensions []productspec.Dimension
if json.Unmarshal([]byte(raw), &dimensions) != nil {
@@ -9,7 +9,6 @@ import (
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -137,18 +136,14 @@ func exactBatchSpecFixture(t *testing.T) (*Service, fixture) {
return testService(db), f
}
func TestBatchSpecMatchPersistsExactMatchBeforePurchaseCreation(t *testing.T) {
func TestBatchSpecMatchPersistsExactMatchButPurchaseDoesNotWaitForIt(t *testing.T) {
service, f := exactBatchSpecFixture(t)
matcher := &batchSpecMatcher{}
service.Matcher = matcher
before, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil || len(before.Items) != 1 || before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
t.Fatalf("unpersisted exact match was not gated: %+v err=%v", before, err)
}
blocked, err := service.BatchCreate(context.Background(), BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID})
if err != nil || blocked.CreatedCount != 0 || blocked.FailedCount != 1 || blocked.Items[0].ReasonCode != CodeMappingRequired {
t.Fatalf("batch create bypassed persisted mapping gate: %+v err=%v", blocked, err)
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStagePurchaseReady {
t.Fatalf("live-probe purchase was incorrectly gated by persisted mapping: %+v err=%v", before, err)
}
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
@@ -186,8 +181,8 @@ func TestExtractedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T) {
service.Matcher = matcher
before, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil || len(before.Items) != 1 || before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
t.Fatalf("extracted uncertain specs did not enter matching: %+v err=%v", before, err)
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStagePurchaseReady {
t.Fatalf("extracted uncertain specs were not purchase-ready for live probing: %+v err=%v", before, err)
}
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil || matched.AutoConfirmedCount != 1 || matcher.calls != 0 {
@@ -236,7 +231,7 @@ func TestBatchPreviewExposesExplicitAIMatchEligibility(t *testing.T) {
t.Fatalf("preview failed: %+v err=%v", preview, err)
}
item := preview.Items[0]
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || item.Eligible || item.ProcessStage != ProcessStageColorMapping {
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || !item.Eligible || item.ProcessStage != ProcessStagePurchaseReady {
t.Fatalf("explicit AI eligibility mismatch: %+v", item)
}
}
+18 -2
View File
@@ -288,8 +288,24 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
if queries != 7 {
t.Fatalf("batch preview used %d queries, want 7 bounded queries including collection eligibility and current purchase rule", queries)
}
if len(response.Items) != 2 || response.Items[0].Eligible || response.Items[1].Eligible || response.Items[0].ReasonCode != CodeMappingRequired {
t.Fatalf("unresolved rows must not be reported as purchase-ready: %+v", response)
if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible || response.EligibleCount != 2 {
t.Fatalf("unresolved rows must be ready for mandatory live probing: %+v", response)
}
}
func TestBatchPreviewAllowsIncompleteArchivedCandidatesForMandatoryLiveProbe(t *testing.T) {
db := testDB(t)
fixture := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", fixture.pdd.ID).Update(
"specs_json",
`[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]}]`,
).Error; err != nil {
t.Fatal(err)
}
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID}})
if err != nil || len(response.Items) != 1 || !response.Items[0].Eligible || response.Items[0].ProcessStage != ProcessStagePurchaseReady {
t.Fatalf("incomplete archive blocked live probe task creation: %+v err=%v", response, err)
}
}
+43 -20
View File
@@ -33,6 +33,19 @@ func (s *Service) Next(ctx context.Context, token string) (*TaskPayload, error)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, internal(err)
}
// A completed probe reserves the device's purchase flow while the server
// resolves the exact specs. Returning that task as a waiting payload keeps
// the Agent from claiming another purchase or collection task and preserves
// the PDD page that the probe just inspected.
var waiting models.PurchaseTask
if err = s.DB.WithContext(ctx).
Where("device_id = ? AND status = ?", d.ID, models.PurchaseTaskStatusSpecProbePending).
Order("created_at, id").
First(&waiting).Error; err == nil {
return s.payload(waiting, nil, false)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, internal(err)
}
now := s.Now()
var candidates []models.PurchaseTask
if err = s.DB.WithContext(ctx).Where("status IN ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?) AND (device_id IS NULL OR device_id = ?)", []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now, d.ID).Order("CASE WHEN device_id IS NULL THEN 1 ELSE 0 END, created_at, id").Limit(100).Find(&candidates).Error; err != nil {
@@ -186,17 +199,14 @@ func (s *Service) Start(ctx context.Context, taskID uint64, req ActionRequest, t
if e = ensureAccountFree(tx, t.PDDAccountID, t.ID, s.Now()); e != nil {
return e
}
phase := models.PurchaseAttemptPhasePurchase
if t.SpecSource == "unresolved" {
phase = models.PurchaseAttemptPhaseSpecProbe
}
phase := purchaseAttemptPhase(t)
ruleSnapshotHash := purchaseRuleSnapshotHash(t.RuleSnapshot)
now := s.Now()
var a models.PurchaseTaskAttempt
if e = tx.Where("task_id = ? AND status = ?", t.ID, models.PurchaseAttemptStatusPending).Order("attempt_number DESC, id DESC").First(&a).Error; e == nil {
if a.DeviceID == nil || *a.DeviceID != d.ID || a.RuleSnapshotHash != ruleSnapshotHash {
if a.DeviceID == nil || *a.DeviceID != d.ID || a.RuleSnapshotHash != ruleSnapshotHash || a.Phase != phase {
failureCode := CodeStateConflict
message := "采购规则快照校验失败,请重新重试任务"
message := "采购任务执行快照校验失败,请重新创建任务"
a.Status = models.PurchaseAttemptStatusFailed
a.ErrorCode = &failureCode
a.ErrorMessage = &message
@@ -266,6 +276,9 @@ func (s *Service) MarkOrderSubmitStarted(ctx context.Context, taskID uint64, req
if t.ExecutionMode != models.PurchaseExecutionModeLive {
return TaskPayload{}, fail(CodeStateConflict, "演练任务不能创建订单")
}
if a.Phase != models.PurchaseAttemptPhasePurchase {
return TaskPayload{}, fail(CodeStateConflict, "规格探测阶段不能进入创建订单边界")
}
if _, e := uuid.Parse(req.RequestID); e != nil {
return TaskPayload{}, fail(CodeInvalidRequest, "requestId 无效")
}
@@ -536,19 +549,29 @@ func (s *Service) resolveProbedSpecs(ctx context.Context, taskID uint64, attempt
}
decision.NoMatch, decision.Decision = true, snapshot
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码"
} else if matched, matchErr := s.matcher().Resolve(ctx, request); matchErr == nil {
snapshot, marshalErr := json.Marshal(matched.Decision)
if marshalErr != nil {
return TaskPayload{}, internal(marshalErr)
}
decision.MappedColor, decision.MappedSize, decision.Source, decision.Decision = matched.MappedColor, matched.MappedSize, matched.Source, snapshot
} else {
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, purchaseMatchReason(matchErr)))
if marshalErr != nil {
return TaskPayload{}, internal(marshalErr)
matched, matchErr := s.resolveProbedMatch(ctx, task, request)
valid := matchErr == nil && (matched.Source == "manual_mapping" || matched.Source == aimatching.SourceExact || matched.Source == aimatching.SourceAI) &&
matchCandidateValid(request.TargetColor, matched.MappedColor, request.Colors) &&
matchCandidateValid(request.TargetSize, matched.MappedSize, request.Sizes)
if valid {
snapshot, marshalErr := json.Marshal(matched.Decision)
if marshalErr != nil {
return TaskPayload{}, internal(marshalErr)
}
decision.MappedColor, decision.MappedSize, decision.Source, decision.Decision = matched.MappedColor, matched.MappedSize, matched.Source, snapshot
} else {
reason := purchaseMatchReason(matchErr)
if matchErr == nil {
reason = "AI 规格匹配结果不属于当次 PDD 候选"
}
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, reason))
if marshalErr != nil {
return TaskPayload{}, internal(marshalErr)
}
decision.NoMatch, decision.Decision = true, snapshot
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", reason
}
decision.NoMatch, decision.Decision = true, snapshot
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", purchaseMatchReason(matchErr)
}
updated, _, err := s.applySpecDecision(ctx, taskID, decision, nil)
if err != nil {
@@ -567,7 +590,7 @@ func purchaseMatchReason(err error) string {
return "AI 规格匹配暂时不可用,请稍后重新创建采购任务"
}
}
return "没有找到可采购的 PDD 颜色或尺码"
return "已采集到当前规格,但未能确定颜色或尺码映射"
}
type probedSpecCandidates struct {
@@ -649,7 +672,7 @@ func (s *Service) withRunning(ctx context.Context, taskID uint64, token string,
func ensureDeviceFree(tx *gorm.DB, deviceID, taskID uint64, now time.Time) error {
var count int64
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", taskID, deviceID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}, []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).Count(&count).Error; e != nil {
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND device_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", taskID, deviceID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending}, models.PurchaseTaskStatusPending, now).Count(&count).Error; e != nil {
return internal(e)
}
if count > 0 {
@@ -668,7 +691,7 @@ func ensureAccountFree(tx *gorm.DB, accountID *uint64, taskID uint64, now time.T
return nil
}
var count int64
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND pdd_account_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", taskID, *accountID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}, []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).Count(&count).Error; e != nil {
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND pdd_account_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", taskID, *accountID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending}, models.PurchaseTaskStatusPending, now).Count(&count).Error; e != nil {
return internal(e)
}
if count > 0 {
@@ -40,6 +40,19 @@ func queuedMatchTask(t *testing.T) (*gorm.DB, fixture, models.PurchaseTask) {
if err != nil {
t.Fatal(err)
}
fingerprint, snapshot, err := currentPurchaseMatchInput(db, task)
if err != nil {
t.Fatal(err)
}
work := models.PurchaseSpecMatchWorkItem{
PurchaseTaskID: task.ID,
Status: models.PurchaseMatchPending,
InputFingerprint: fingerprint,
InputSnapshotJSON: snapshot,
}
if err := db.Create(&work).Error; err != nil {
t.Fatal(err)
}
return db, f, task
}
@@ -0,0 +1,207 @@
package purchase
import (
"context"
"encoding/json"
"strings"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
)
// resolveProbedMatch keeps the live probe as the source of executable labels.
// Confirmed product mappings may guide the decision only when they still map
// uniquely to a label observed by this attempt.
func (s *Service) resolveProbedMatch(ctx context.Context, task models.PurchaseTask, request aimatching.MatchRequest) (aimatching.MatchResult, error) {
resolvedColor, resolvedSize := "", ""
roleSources := map[string]string{}
mappings := probeGuidanceFromSnapshot(task.SpecDecisionSnapshot)
if candidate, ok := currentMappedCandidate(mappings.color.value, request.Colors); ok {
resolvedColor, roleSources[shopeeproduct.RoleColor] = candidate, mappings.color.source
}
if candidate, ok := currentMappedCandidate(mappings.size.value, request.Sizes); ok {
resolvedSize, roleSources[shopeeproduct.RoleSize] = candidate, mappings.size.source
}
if task.TargetColorSnapshot != "" && resolvedColor == "" {
if exact, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, Colors: request.Colors}); ok {
resolvedColor, roleSources[shopeeproduct.RoleColor] = exact.MappedColor, aimatching.SourceExact
}
}
if task.TargetSizeSnapshot != "" && resolvedSize == "" {
if exact, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetSize: task.TargetSizeSnapshot, Sizes: request.Sizes}); ok {
resolvedSize, roleSources[shopeeproduct.RoleSize] = exact.MappedSize, aimatching.SourceExact
}
}
missingColor := task.TargetColorSnapshot != "" && resolvedColor == ""
missingSize := task.TargetSizeSnapshot != "" && resolvedSize == ""
var providerDecision aimatching.DecisionSnapshot
if missingColor || missingSize {
remaining := aimatching.MatchRequest{}
if missingColor {
remaining.TargetColor, remaining.Colors = task.TargetColorSnapshot, request.Colors
}
if missingSize {
remaining.TargetSize, remaining.Sizes = task.TargetSizeSnapshot, request.Sizes
}
matched, err := s.matcher().Resolve(ctx, remaining)
if err != nil {
return aimatching.MatchResult{}, err
}
if matched.Source != aimatching.SourceExact && matched.Source != aimatching.SourceAI {
return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "规格匹配来源无效"}
}
providerDecision = matched.Decision
if missingColor {
if !matchCandidateValid(remaining.TargetColor, matched.MappedColor, remaining.Colors) {
return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "AI 返回的颜色不属于当次候选"}
}
resolvedColor, roleSources[shopeeproduct.RoleColor] = matched.MappedColor, matched.Source
}
if missingSize {
if !matchCandidateValid(remaining.TargetSize, matched.MappedSize, remaining.Sizes) {
return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "AI 返回的尺码不属于当次候选"}
}
resolvedSize, roleSources[shopeeproduct.RoleSize] = matched.MappedSize, matched.Source
}
}
source := probeSourceSummary(roleSources)
reason := "当次候选完成确定性匹配"
if containsRoleSource(roleSources, "manual_mapping") || containsRoleSource(roleSources, aimatching.SourceAI) {
reason = "已确认映射经当次候选验证,未解决规格按确定性或 AI 匹配"
}
result := aimatching.RecordedMatch(request, source, resolvedColor, resolvedSize, reason)
result.Decision.RoleSources = roleSources
result.Decision.Provider = providerDecision.Provider
result.Decision.Model = providerDecision.Model
result.Decision.Confidence = providerDecision.Confidence
return result, nil
}
type confirmedProbeMapping struct {
value string
source string
invalid bool
}
type confirmedProbeMappingSet struct {
color confirmedProbeMapping
size confirmedProbeMapping
}
type probeGuidanceSnapshot struct {
ConfirmedMappings map[string]probeGuidanceMapping `json:"confirmedMappings,omitempty"`
}
type probeGuidanceMapping struct {
Value string `json:"value"`
Source string `json:"source"`
}
func newProbeGuidanceSnapshot(raw, targetColor, targetSize string) (string, error) {
mappings := confirmedProbeMappings(raw, targetColor, targetSize)
snapshot := probeGuidanceSnapshot{ConfirmedMappings: map[string]probeGuidanceMapping{}}
if mappings.color.value != "" {
snapshot.ConfirmedMappings[shopeeproduct.RoleColor] = probeGuidanceMapping{Value: mappings.color.value, Source: mappings.color.source}
}
if mappings.size.value != "" {
snapshot.ConfirmedMappings[shopeeproduct.RoleSize] = probeGuidanceMapping{Value: mappings.size.value, Source: mappings.size.source}
}
encoded, err := json.Marshal(snapshot)
return string(encoded), err
}
func probeGuidanceFromSnapshot(raw string) confirmedProbeMappingSet {
var snapshot probeGuidanceSnapshot
if json.Unmarshal([]byte(raw), &snapshot) != nil {
return confirmedProbeMappingSet{}
}
result := confirmedProbeMappingSet{}
if mapping, ok := snapshot.ConfirmedMappings[shopeeproduct.RoleColor]; ok {
result.color = confirmedProbeMapping{value: strings.TrimSpace(mapping.Value), source: mapping.Source}
}
if mapping, ok := snapshot.ConfirmedMappings[shopeeproduct.RoleSize]; ok {
result.size = confirmedProbeMapping{value: strings.TrimSpace(mapping.Value), source: mapping.Source}
}
return result
}
func confirmedProbeMappings(raw, targetColor, targetSize string) confirmedProbeMappingSet {
var specs []shopeeproduct.SpecDimension
if json.Unmarshal([]byte(raw), &specs) != nil {
return confirmedProbeMappingSet{}
}
result := confirmedProbeMappingSet{}
for _, dimension := range specs {
for _, value := range dimension.Values {
if value.Mapping == nil || value.Mapping.Status != shopeeproduct.MappingStatusConfirmed {
continue
}
mapping := confirmedProbeMapping{value: strings.TrimSpace(value.Mapping.PDDValue), source: mapSource(value.Mapping.Source)}
switch {
case dimension.Role == shopeeproduct.RoleColor && value.Name == targetColor:
result.color = mergeConfirmedProbeMapping(result.color, mapping)
case dimension.Role == shopeeproduct.RoleSize && value.Name == targetSize:
result.size = mergeConfirmedProbeMapping(result.size, mapping)
}
}
}
return result
}
func mergeConfirmedProbeMapping(current, incoming confirmedProbeMapping) confirmedProbeMapping {
if current.invalid {
return current
}
if current.value == "" {
return incoming
}
if current.value != incoming.value {
return confirmedProbeMapping{invalid: true}
}
return current
}
func currentMappedCandidate(mapped string, candidates []string) (string, bool) {
mapped = strings.TrimSpace(mapped)
if mapped == "" {
return "", false
}
matches := make([]string, 0, 1)
seen := map[string]bool{}
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" || seen[candidate] || aimatching.Normalize(candidate) != aimatching.Normalize(mapped) {
continue
}
seen[candidate] = true
matches = append(matches, candidate)
}
if len(matches) != 1 {
return "", false
}
return matches[0], true
}
func probeSourceSummary(roleSources map[string]string) string {
if containsRoleSource(roleSources, "manual_mapping") {
return "manual_mapping"
}
if containsRoleSource(roleSources, aimatching.SourceAI) {
return aimatching.SourceAI
}
return aimatching.SourceExact
}
func containsRoleSource(roleSources map[string]string, target string) bool {
for _, source := range roleSources {
if source == target {
return true
}
}
return false
}
+1 -2
View File
@@ -69,8 +69,7 @@ func (s *Service) ProcessStages(ctx context.Context, ids []uint64) (map[uint64]P
return nil, err
}
for _, id := range ids {
preview := s.previewFromDataset(ctx, id, dataset, false, guard)
preview.enforcePersistedMatch(id, dataset)
preview := s.previewFromDataset(id, dataset, guard)
qualification := aiMatchQualificationForDataset(id, dataset)
preview.AIMatchEligible = qualification.Eligible
preview.AIMatchDisabledReason = qualification.DisabledReason
+16 -4
View File
@@ -87,6 +87,9 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
if err := validatePurchaseResetState(tx, task); err != nil {
return err
}
if task.SpecSource != "unresolved" && !hasCompletePurchaseSpec(task) {
return fail(CodeMappingRequired, "任务没有完整的精确商品规格,请创建新采购任务")
}
deviceRecord, err := lockPurchaseResetDevice(tx, task, authenticated)
if err != nil {
return err
@@ -130,10 +133,7 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
return conflictOrInternal(err)
}
phase := models.PurchaseAttemptPhasePurchase
if task.SpecSource == "unresolved" {
phase = models.PurchaseAttemptPhaseSpecProbe
}
phase := purchaseAttemptPhase(task)
// MySQL normalizes values written to a JSON column. Reload the task before
// hashing so the pending attempt uses the exact representation Start will
// read later, rather than the pre-persistence DefaultLiveRule bytes.
@@ -155,6 +155,18 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
return response, err
}
func purchaseAttemptPhase(task models.PurchaseTask) string {
if task.SpecSource == "unresolved" || !hasCompletePurchaseSpec(task) {
return models.PurchaseAttemptPhaseSpecProbe
}
return models.PurchaseAttemptPhasePurchase
}
func hasCompletePurchaseSpec(task models.PurchaseTask) bool {
return (strings.TrimSpace(task.TargetColorSnapshot) == "" || strings.TrimSpace(task.MappedColorSnapshot) != "") &&
(strings.TrimSpace(task.TargetSizeSnapshot) == "" || strings.TrimSpace(task.MappedSizeSnapshot) != "")
}
func validatePurchaseResetState(tx *gorm.DB, task models.PurchaseTask) error {
switch task.Status {
case models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderCreated, models.PurchaseTaskStatusOrderResultUnknown:
+30
View File
@@ -108,6 +108,36 @@ func TestPurchaseResetReusesTaskRefreshesRuleAndIsIdempotent(t *testing.T) {
}
}
func TestPurchaseResetRejectsResolvedDecisionWithoutExactSpec(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
service := testService(db)
failed := failedLiveTask(t, db, service, f)
decisionID := uuid.NewString()
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID).Updates(map[string]any{
"spec_source": "ai_match", "spec_decision_request_id": decisionID,
"mapped_color_snapshot": "", "mapped_size_snapshot": "",
}).Error; err != nil {
t.Fatal(err)
}
_, err := service.ResetForDevice(context.Background(), failed.ID, PurchaseResetRequest{RequestID: uuid.NewString()}, f.token)
if code(err) != CodeMappingRequired {
t.Fatalf("resolved decision without exact spec was reset: %v", err)
}
var task models.PurchaseTask
if queryErr := db.First(&task, failed.ID).Error; queryErr != nil {
t.Fatal(queryErr)
}
if task.Status != models.PurchaseTaskStatusFailed {
t.Fatalf("rejected reset changed task status: %+v", task)
}
var attemptCount int64
if queryErr := db.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", failed.ID).Count(&attemptCount).Error; queryErr != nil || attemptCount != 0 {
t.Fatalf("rejected reset created attempt: count=%d err=%v", attemptCount, queryErr)
}
}
func TestPurchaseStartPersistsPendingAttemptSnapshotMismatchAsFailure(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
+14 -15
View File
@@ -218,7 +218,12 @@ func (s *Service) retryEligibility(ctx context.Context, task models.PurchaseTask
return decision
}
preview := s.previewOne(ctx, *task.SYBProductID)
_, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
if ruleErr != nil {
code, message, _ := serviceErrorFields(ruleErr)
return retryDecision{ReasonCode: code, Reason: message}
}
preview := s.previewOneDeterministic(ctx, *task.SYBProductID, purchasecontract.EffectivePriceGuard(currentRule))
if !preview.Eligible {
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
@@ -230,10 +235,9 @@ func (s *Service) retryEligibility(ctx context.Context, task models.PurchaseTask
}
// retryQueryEligibility is deliberately limited to local task and device
// safety checks. Admin list/detail queries must never call previewOne because
// previewOne can fall back to the external AI provider for unresolved specs.
// The batch-retry command runs retryEligibility and performs that complete,
// current-archive validation only after the operator submits the retry.
// safety checks. The batch-retry command performs deterministic archive and
// price validation, but specification resolution belongs to the new task's
// mandatory live probe and never calls AI before task creation.
func (s *Service) retryQueryEligibility(ctx context.Context, task models.PurchaseTask, checkDeviceBusy bool) retryDecision {
decision := s.retryStateEligibility(ctx, task)
if !decision.Allowed {
@@ -263,25 +267,20 @@ func (s *Service) continuePurchaseEligibility(ctx context.Context, task models.P
guard := purchasecontract.EffectivePriceGuard(currentRule)
syb, found := dataset.sybByID[*task.SYBProductID]
if !found || syb.ShopeeProductID == nil {
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
shopee, found := dataset.shopeeByID[*syb.ShopeeProductID]
if !found || shopee.PDDProductID == nil {
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
pdd, found := dataset.pddByID[*shopee.PDDProductID]
_, found = dataset.pddByID[*shopee.PDDProductID]
if !found {
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
candidates, _ := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
if source == "unresolved" || !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
return retryDecision{ReasonCode: CodeMappingRequired, Reason: "规格匹配已失效,请在 Admin 重新确认"}
}
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
if !preview.Eligible {
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
+3 -3
View File
@@ -223,7 +223,7 @@ func TestAdminListRetryEligibilityRejectsBusyOriginalDevice(t *testing.T) {
}
}
func TestAdminQueriesDoNotResolveSpecsButBatchRetryStillDoes(t *testing.T) {
func TestAdminQueriesAndBatchRetryDoNotResolveArchivedSpecs(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
service := testService(db)
@@ -253,8 +253,8 @@ func TestAdminQueriesDoNotResolveSpecsButBatchRetryStillDoes(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if matcher.calls != 1 || retry.CreatedCount != 0 || retry.FailedCount != 1 || retry.Items[0].ReasonCode != CodeMappingRequired {
t.Fatalf("batch retry skipped full matching: result=%+v matcherCalls=%d", retry, matcher.calls)
if matcher.calls != 0 || retry.CreatedCount != 1 || retry.FailedCount != 0 {
t.Fatalf("batch retry did not create a mandatory probe task: result=%+v matcherCalls=%d", retry, matcher.calls)
}
}
+13 -75
View File
@@ -54,11 +54,6 @@ type SpecMatcher interface {
Resolve(context.Context, aimatching.MatchRequest) (aimatching.MatchResult, error)
}
type externalMatchPlan struct {
fingerprint string
snapshot string
}
type matchFingerprint struct {
SYBProductID uint64 `json:"sybProductId"`
TargetColor string `json:"targetColor"`
@@ -96,51 +91,6 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas
return call.task, call.replayed, call.err
}
func (s *Service) prepareExternalMatch(ctx context.Context, req CreateRequest) (*externalMatchPlan, error) {
if req.ExecutionMode != models.PurchaseExecutionModeLive || req.SYBProductID == nil {
return nil, nil
}
var syb models.SYBProduct
if err := s.DB.WithContext(ctx).First(&syb, *req.SYBProductID).Error; err != nil {
return nil, notFound(err, "顺云宝商品不存在")
}
if syb.ShopeeProductID == nil {
return nil, fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品")
}
var shopee models.ShopeeProduct
if err := s.DB.WithContext(ctx).First(&shopee, *syb.ShopeeProductID).Error; err != nil {
return nil, notFound(err, "蝦皮商品不存在")
}
if shopee.PDDProductID == nil {
return nil, fail(CodeInvalidRequest, "该蝦皮商品尚未关联拼多多商品")
}
var pdd models.PDDProduct
if err := s.DB.WithContext(ctx).First(&pdd, *shopee.PDDProductID).Error; err != nil {
return nil, notFound(err, "拼多多商品不存在")
}
targetColor, targetSize := strings.TrimSpace(req.TargetColor), strings.TrimSpace(req.TargetSize)
if targetColor == "" {
targetColor = syb.TargetColor
}
if targetSize == "" {
targetSize = syb.TargetSize
}
_, _, source := confirmedMappings(shopee.SpecsJSON, targetColor, targetSize)
candidates, usable := archiveCandidates(pdd.SpecsJSON, targetColor, targetSize)
if pdd.Status != "active" || source != "unresolved" || !usable {
return nil, nil
}
request := aimatching.MatchRequest{TargetColor: targetColor, TargetSize: targetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
if _, deterministic := aimatching.DeterministicMatch(request); deterministic {
return nil, nil
}
fingerprint, snapshot, err := creationMatchInput(syb, shopee, pdd, request)
if err != nil {
return nil, internal(err)
}
return &externalMatchPlan{fingerprint: fingerprint, snapshot: snapshot}, nil
}
func creationMatchInput(syb models.SYBProduct, shopee models.ShopeeProduct, pdd models.PDDProduct, request aimatching.MatchRequest) (string, string, error) {
canonical := func(raw string) (json.RawMessage, error) {
var value any
@@ -194,10 +144,6 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return models.PurchaseTask{}, false, internal(err)
}
externalPlan, err := s.prepareExternalMatch(ctx, req)
if err != nil {
return models.PurchaseTask{}, false, err
}
replayed := false
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("create_request_id = ?", req.RequestID).First(&out).Error; err == nil {
@@ -334,22 +280,25 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
if currency == "" {
currency = shopee.Currency
}
mappedColor, mappedSize, specSource = confirmedMappings(shopee.SpecsJSON, targetColor, targetSize)
// Every new SYB purchase must use the candidates observed on the
// current PDD page. Freeze confirmed mappings only as guidance for
// validation against that future probe; they never skip the probe.
mappedColor, mappedSize, specSource = "", "", "unresolved"
guidance, marshalErr := newProbeGuidanceSnapshot(shopee.SpecsJSON, targetColor, targetSize)
if marshalErr != nil {
return internal(marshalErr)
}
decisionSnapshot = guidance
}
candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, targetColor, targetSize)
matchRequest := aimatching.MatchRequest{TargetColor: targetColor, TargetSize: targetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
if externalPlan != nil {
fingerprint, fingerprintErr := creationMatchFingerprint(syb, shopee, pdd, matchRequest)
if fingerprintErr != nil {
return internal(fingerprintErr)
}
if fingerprint != externalPlan.fingerprint {
return fail(CodeMappingRequired, "规格匹配输入已变化,请重新创建采购任务")
}
}
if taskType == models.PurchaseTaskTypeStock {
// Direct stock selection was already validated above. It never enters
// mapping, deterministic matching, or the external AI fallback.
} else if req.ExecutionMode == models.PurchaseExecutionModeLive {
// The first attempt is always spec_probe for an SYB purchase. The
// exact task-level decision is frozen only from that probe result.
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if pdd.Status != "active" {
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if specSource == "manual_mapping" || specSource == "exact_match" || specSource == "ai_match" {
@@ -365,8 +314,6 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
}
} else if !archiveUsable {
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if externalPlan != nil {
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if match, matched := aimatching.DeterministicMatch(matchRequest); !matched {
// #190:确定性匹配失败不再拒绝,降级为 unresolved 交给规格探测。
mappedColor, mappedSize, specSource = "", "", "unresolved"
@@ -404,17 +351,8 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
if err := tx.Model(&out).Update("address_suffix", out.AddressSuffix).Error; err != nil {
return internal(err)
}
if externalPlan != nil {
work := models.PurchaseSpecMatchWorkItem{PurchaseTaskID: out.ID, Status: models.PurchaseMatchPending, InputFingerprint: externalPlan.fingerprint, InputSnapshotJSON: externalPlan.snapshot}
if err := tx.Create(&work).Error; err != nil {
return internal(err)
}
}
return nil
})
if err == nil && !replayed && externalPlan != nil && s.StartMatching != nil {
s.StartMatching(s.DB)
}
return out, replayed, err
}
+321 -25
View File
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/device"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
@@ -29,6 +30,19 @@ type fixture struct {
token string
}
type liveProbeMatcher struct {
result aimatching.MatchResult
err error
calls int
request aimatching.MatchRequest
}
func (matcher *liveProbeMatcher) Resolve(_ context.Context, request aimatching.MatchRequest) (aimatching.MatchResult, error) {
matcher.calls++
matcher.request = request
return matcher.result, matcher.err
}
func testDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared&_foreign_keys=on", strings.ReplaceAll(t.Name(), "/", "_"))
@@ -107,6 +121,30 @@ func createLive(t *testing.T, s *Service, f fixture) (models.PurchaseTask, error
r, _, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true)})
return r, err
}
func startLivePurchaseAfterProbe(t *testing.T, s *Service, f fixture, task models.PurchaseTask) TaskPayload {
t.Helper()
if _, err := s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
probeAttempt, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || probeAttempt.Phase != models.PurchaseAttemptPhaseSpecProbe {
t.Fatalf("probe start: %+v %v", probeAttempt, err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: probeAttempt.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)}
if _, err = s.SubmitResult(context.Background(), task.ID, probe, f.token); err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
purchaseAttempt, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || purchaseAttempt.Phase != models.PurchaseAttemptPhasePurchase {
t.Fatalf("purchase start: %+v %v", purchaseAttempt, err)
}
return purchaseAttempt
}
func code(err error) string {
var e *ServiceError
if errors.As(err, &e) {
@@ -224,9 +262,23 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T)
if err != nil {
t.Fatal(err)
}
if start.Phase != models.PurchaseAttemptPhasePurchase {
if start.Phase != models.PurchaseAttemptPhaseSpecProbe {
t.Fatalf("unexpected phase %s", start.Phase)
}
if _, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeStateConflict {
t.Fatalf("probe entered order boundary: %v", err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: start.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)}
if _, err = s.SubmitResult(context.Background(), task.ID, probe, f.token); err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
start, err = s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || start.Phase != models.PurchaseAttemptPhasePurchase {
t.Fatalf("purchase start: %+v %v", start, err)
}
if _, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
@@ -243,7 +295,7 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T)
}
var count int64
db.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", task.ID).Count(&count)
if count != 1 || first.Status != models.PurchaseTaskStatusOrderCreated {
if count != 2 || first.Status != models.PurchaseTaskStatusOrderCreated {
t.Fatalf("duplicate attempt or wrong status: %d %+v", count, first)
}
var stored models.PurchaseTask
@@ -344,6 +396,198 @@ func TestSlowPathUsesTwoAttemptsAndFreezesDecision(t *testing.T) {
}
}
func TestLiveProbeCallsAIOnlyAfterCandidatesAreReturned(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil {
t.Fatal(err)
}
request := aimatching.MatchRequest{TargetColor: "象牙白", TargetSize: "XL", Colors: []string{"米白色", "白色"}, Sizes: []string{"XL"}}
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(request, aimatching.SourceAI, "米白色", "XL", "在当次候选中唯一匹配")}
s := testService(db)
s.Matcher = matcher
task, err := createLive(t, s, f)
if err != nil || matcher.calls != 0 || task.SpecSource != "unresolved" {
t.Fatalf("task creation called matcher or skipped probe: task=%+v calls=%d err=%v", task, matcher.calls, err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || first.Phase != models.PurchaseAttemptPhaseSpecProbe {
t.Fatalf("probe start: %+v %v", first, err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["米白色","白色"]},{"key":"size","values":["XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || matcher.calls != 1 || resolved.Status != models.PurchaseTaskStatusPending || resolved.MappedColor != "米白色" || resolved.MappedSize != "XL" {
t.Fatalf("probe AI result: %+v calls=%d err=%v", resolved, matcher.calls, err)
}
}
func TestLiveProbeReusesConfirmedColorAndResolvesCurrentSizeWithoutAI(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
"target_color": "薑黃色",
"target_size": "5XL",
}).Error; err != nil {
t.Fatal(err)
}
specs := []shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "薑黃色", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "黄色", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "5XL", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "XXXXXL", Source: shopeeproduct.MappingSourceAIMatch, Status: shopeeproduct.MappingStatusConfirmed, Reason: "历史档案原文"}}}},
}
raw, _ := json.Marshal(specs)
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", string(raw)).Error; err != nil {
t.Fatal(err)
}
matcher := &liveProbeMatcher{err: errors.New("AI should not be called")}
s := testService(db)
s.Matcher = matcher
task, err := createLive(t, s, f)
if err != nil || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
t.Fatalf("live task skipped mandatory probe: %+v err=%v", task, err)
}
if err = db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", `[]`).Error; err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["白色","黄色","黑色"]},{"key":"size","values":["4XL","5XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || matcher.calls != 0 || resolved.Status != models.PurchaseTaskStatusPending {
t.Fatalf("confirmed mapping resolution failed: %+v calls=%d err=%v", resolved, matcher.calls, err)
}
if resolved.MappedColor != "黄色" || resolved.MappedSize != "5XL" {
t.Fatalf("task did not freeze current probe labels: %+v", resolved)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil {
t.Fatal(err)
}
var decision struct {
RoleSources map[string]string `json:"roleSources"`
}
if err = json.Unmarshal([]byte(saved.SpecDecisionSnapshot), &decision); err != nil {
t.Fatal(err)
}
if saved.SpecSource != "manual_mapping" || decision.RoleSources[shopeeproduct.RoleColor] != "manual_mapping" || decision.RoleSources[shopeeproduct.RoleSize] != aimatching.SourceExact {
t.Fatalf("mixed role sources were not audited: task=%+v decision=%+v", saved, decision)
}
}
func TestLiveProbeCallsAIOnlyForRoleWithoutCurrentConfirmedMapping(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "薑黃色").Error; err != nil {
t.Fatal(err)
}
specs := []shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "薑黃色", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "旧黄色", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "XL", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "XL", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
}
raw, _ := json.Marshal(specs)
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", string(raw)).Error; err != nil {
t.Fatal(err)
}
aiRequest := aimatching.MatchRequest{TargetColor: "薑黃色", Colors: []string{"黄色"}}
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(aiRequest, aimatching.SourceAI, "黄色", "", "当前颜色候选匹配")}
s := testService(db)
s.Matcher = matcher
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黄色"]},{"key":"size","values":["XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || matcher.calls != 1 || resolved.MappedColor != "黄色" || resolved.MappedSize != "XL" {
t.Fatalf("partial AI resolution failed: %+v request=%+v err=%v", resolved, matcher.request, err)
}
if matcher.request.TargetColor != "薑黃色" || matcher.request.TargetSize != "" || len(matcher.request.Colors) != 1 || len(matcher.request.Sizes) != 0 {
t.Fatalf("resolved size was unnecessarily sent to AI: %+v", matcher.request)
}
}
func TestLiveProbeRejectsMatcherValueOutsideCurrentCandidates(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙黑").Error; err != nil {
t.Fatal(err)
}
request := aimatching.MatchRequest{TargetColor: "象牙黑", Colors: []string{"黑色"}}
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(request, aimatching.SourceAI, "候选外颜色", "XL", "无效返回")}
s := testService(db)
s.Matcher = matcher
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || resolved.Status != models.PurchaseTaskStatusFailed || resolved.MappedColor != "" || resolved.MappedSize != "" {
t.Fatalf("out-of-candidate result was accepted: %+v err=%v", resolved, err)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil || saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_NOT_MATCHED" {
t.Fatalf("outside-candidate failure was not persisted: %+v err=%v", saved, err)
}
if saved.ErrorMessage == nil || *saved.ErrorMessage != "已采集到当前规格,但未能确定颜色或尺码映射" {
t.Fatalf("outside-candidate failure reason was not preserved: %+v", saved)
}
}
func TestLiveProbeUsesAccurateMessageWhenCompleteCandidatesCannotBeMatched(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "薑黃色").Error; err != nil {
t.Fatal(err)
}
s := testService(db)
s.Matcher = &liveProbeMatcher{err: &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "no match"}}
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黄色"]},{"key":"size","values":["XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || resolved.Status != models.PurchaseTaskStatusFailed {
t.Fatalf("no-match probe did not converge: %+v err=%v", resolved, err)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil {
t.Fatal(err)
}
if saved.ErrorMessage == nil || *saved.ErrorMessage != "已采集到当前规格,但未能确定颜色或尺码映射" {
t.Fatalf("complete candidates used misleading failure reason: %+v", saved)
}
}
func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
@@ -428,7 +672,7 @@ func TestSpecResolutionAllowedMatrix(t *testing.T) {
}
}
func TestCreateUsesDeterministicTraditionalAndWeightMatching(t *testing.T) {
func TestLiveProbeUsesDeterministicTraditionalAndWeightMatching(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"浅蓝色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"4XL 160-170斤","selectable":true}]}]`).Error; err != nil {
@@ -437,12 +681,26 @@ func TestCreateUsesDeterministicTraditionalAndWeightMatching(t *testing.T) {
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{"target_color": "淺藍色", "target_size": "4XL 80-85公斤"}).Error; err != nil {
t.Fatal(err)
}
task, err := createLive(t, testService(db), f)
if err != nil || task.SpecSource != "exact_match" || task.MappedColorSnapshot != "浅蓝色" || task.MappedSizeSnapshot != "4XL 160-170斤" {
t.Fatalf("deterministic match failed: %+v %v", task, err)
s := testService(db)
task, err := createLive(t, s, f)
if err != nil || task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
t.Fatalf("live task skipped probe: %+v %v", task, err)
}
if strings.Contains(task.SpecDecisionSnapshot, "apiKey") || !strings.Contains(task.SpecDecisionSnapshot, "exact_match") {
t.Fatalf("task decision snapshot is unsafe or incomplete: %s", task.SpecDecisionSnapshot)
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || first.Phase != models.PurchaseAttemptPhaseSpecProbe {
t.Fatalf("probe start: %+v %v", first, err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["浅蓝色"]},{"key":"size","values":["4XL 160-170斤"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || resolved.MappedColor != "浅蓝色" || resolved.MappedSize != "4XL 160-170斤" {
t.Fatalf("deterministic probe match failed: %+v %v", resolved, err)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil || strings.Contains(saved.SpecDecisionSnapshot, "apiKey") || !strings.Contains(saved.SpecDecisionSnapshot, "exact_match") {
t.Fatalf("task decision snapshot is unsafe or incomplete: %s err=%v", saved.SpecDecisionSnapshot, err)
}
}
@@ -457,12 +715,12 @@ func TestCreateDoesNotTrustMappedLabelsFromRequest(t *testing.T) {
SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MappedColor: "白色", MappedSize: "XL",
MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true),
})
if err != nil || task.SpecSource != "exact_match" || task.MappedColorSnapshot != "黑色" || task.MappedSizeSnapshot != "XL" {
t.Fatalf("request-provided mapping must not override server match: %+v %v", task, err)
if err != nil || task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
t.Fatalf("request-provided mapping must not skip live probe: %+v %v", task, err)
}
}
func TestCreateQueuesUsableArchiveWhenDeterministicMatchFails(t *testing.T) {
func TestCreateDoesNotQueueArchiveMatchingBeforeLiveProbe(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"L","selectable":true}]}]`).Error; err != nil {
@@ -475,12 +733,12 @@ func TestCreateQueuesUsableArchiveWhenDeterministicMatchFails(t *testing.T) {
t.Fatalf("task=%+v err=%v", task, err)
}
var count int64
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 1 {
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("work count=%d err=%v", count, err)
}
}
func TestCreateQueuesExternalMatchingWithoutCallingProvider(t *testing.T) {
func TestCreateDispatchesProbeInsteadOfExternalArchiveMatching(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000},{"name":"米白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
@@ -495,12 +753,48 @@ func TestCreateQueuesExternalMatchingWithoutCallingProvider(t *testing.T) {
if err != nil || task.ID == 0 || task.SpecSource != "unresolved" {
t.Fatalf("queued task=%+v err=%v", task, err)
}
var work models.PurchaseSpecMatchWorkItem
if err := db.Where("purchase_task_id = ?", task.ID).First(&work).Error; err != nil || work.Status != models.PurchaseMatchPending || work.InputFingerprint == "" {
t.Fatalf("work=%+v err=%v", work, err)
var count int64
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("unexpected archive match work count=%d err=%v", count, err)
}
if next, err := service.Next(context.Background(), f.token); err != nil || next != nil {
t.Fatalf("matching task dispatched: %+v %v", next, err)
if next, err := service.Next(context.Background(), f.token); err != nil || next == nil || next.MappedColor != "" || next.MappedSize != "" || !next.SpecResolutionAllowed {
t.Fatalf("probe task was not dispatchable: %+v %v", next, err)
}
}
func TestNextReturnsAssignedProbeWaitingTaskBeforeOtherWork(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1200}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
t.Fatal(err)
}
service := testService(db)
request := StockCreateRequest{
RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive,
PDDProductID: f.pdd.ID, DeviceID: &f.device.ID, Color: "黑色", Size: "XL",
Quantity: 1, MinUnitPriceCent: 900, MaxUnitPriceCent: 1500,
}
waiting, _, err := service.CreateStock(context.Background(), request)
if err != nil {
t.Fatal(err)
}
request.RequestID = uuid.NewString()
other, _, err := service.CreateStock(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if err = db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", waiting.ID).Updates(map[string]any{
"status": models.PurchaseTaskStatusSpecProbePending, "mapped_color_snapshot": "", "mapped_size_snapshot": "",
}).Error; err != nil {
t.Fatal(err)
}
next, err := service.Next(context.Background(), f.token)
if err != nil || next == nil || next.TaskID != waiting.ID || next.Status != models.PurchaseTaskStatusSpecProbePending {
t.Fatalf("waiting probe task not reserved: next=%+v other=%d err=%v", next, other.ID, err)
}
if _, err = service.Claim(context.Background(), other.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeDeviceBusy {
t.Fatalf("other purchase claimed while probe waits: %v", err)
}
}
@@ -509,9 +803,10 @@ func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) {
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, _ := createLive(t, s, f)
s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
started, _ := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
started := startLivePurchaseAfterProbe(t, s, f, task)
if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
actual := int64(1999)
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
@@ -527,7 +822,7 @@ func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) {
t.Fatalf("unknown task evidence not preserved: %+v", saved)
}
var attempt models.PurchaseTaskAttempt
if err := db.Where("task_id = ?", task.ID).First(&attempt).Error; err != nil {
if err := db.Where("task_id = ?", task.ID).Order("attempt_number DESC").First(&attempt).Error; err != nil {
t.Fatal(err)
}
if attempt.ErrorCode == nil || *attempt.ErrorCode != CodeOrderTimeMissing || attempt.ErrorMessage == nil {
@@ -544,9 +839,10 @@ func TestOrderUnknownRejectsOrderFieldsAndUnapprovedFailureCode(t *testing.T) {
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, _ := createLive(t, s, f)
s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
started, _ := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
started := startLivePurchaseAfterProbe(t, s, f, task)
if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
+13 -4
View File
@@ -85,7 +85,8 @@ func StartImport(ctx context.Context, db *gorm.DB, request ImportRequest, actor
}
run, err := NewSyncRunService(db).Create(ctx, CreateSyncRunInput{
DateFrom: request.DateFrom, DateTo: request.DateTo, ShopFilterHash: enabledShopHash(enabled),
OperatorID: actor.ID, OperatorName: strings.TrimSpace(actor.Name),
ShopFilterSnapshot: cloneShopSnapshot(enabled),
OperatorID: actor.ID, OperatorName: strings.TrimSpace(actor.Name),
})
if err != nil {
importGate.Unlock()
@@ -97,7 +98,7 @@ func StartImport(ctx context.Context, db *gorm.DB, request ImportRequest, actor
importGate.running = true
importGate.Unlock()
go runImport(db, run.ID, request, settings)
go runImport(db, run.ID, request, settings, cloneShopSnapshot(enabled))
return StartImportResult{RunID: run.ID, Status: run.Status}, nil
}
@@ -145,7 +146,7 @@ func (handler Handler) Import(c *gin.Context) {
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"runId": result.RunID, "status": result.Status}})
}
func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config.SYB) {
func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config.SYB, allowed map[string]string) {
defer func() { importGate.Lock(); importGate.running = false; importGate.Unlock() }()
ctx, cancel := context.WithTimeout(context.Background(), importTimeout)
defer cancel()
@@ -156,7 +157,7 @@ func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config
OcrURL: settings.OcrURL, OcrMaxAttempts: settings.OcrMaxAttempts,
})
if err == nil {
report, err = SyncWithProgress(ctx, db, client, SyncConfig{PageSize: settings.PageSize, MaxMatches: settings.MaxMatches}, request.DateFrom, request.DateTo,
report, err = SyncWithShopSnapshot(ctx, db, client, SyncConfig{PageSize: settings.PageSize, MaxMatches: settings.MaxMatches}, request.DateFrom, request.DateTo, allowed,
func(progress SyncProgress) error { return runs.UpdateProgress(ctx, runID, progress) })
}
status := SyncRunSucceeded
@@ -168,6 +169,14 @@ func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config
_ = runs.Finish(finishCtx, runID, status, report, err)
}
func cloneShopSnapshot(source map[string]string) map[string]string {
cloned := make(map[string]string, len(source))
for normalized, display := range source {
cloned[normalized] = display
}
return cloned
}
func claimUint64(value any) uint64 {
switch typed := value.(type) {
case float64:
+26 -7
View File
@@ -13,6 +13,13 @@ import (
var bracketPattern = regexp.MustCompile(`【[^】]*】`)
// explicitSizePattern recognizes only values whose spelling carries a strong
// size signal. SYB has now been observed returning both "color,size" and
// "size,color". A color dictionary would inevitably guess at product-specific
// labels, so role reversal is allowed only when exactly one side matches this
// deliberately narrow pattern.
var explicitSizePattern = regexp.MustCompile(`(?i)^(?:均(?:码|碼|号|號)|one\s*size|free\s*size|x{0,4}[sml]|[2-9]xl|(?:加大|大|中|小)(?:码|碼|号|號)|\d+(?:\.\d+)?(?:cm|mm|m|码|碼|号|號|公分)|\d+(?:\.\d+)?(?:[-~~至到]\d+(?:\.\d+)?)?(?:斤|公斤|千克|kg))$`)
// ambiguousPattern flags leftover separators or multi-token noise after
// bracket stripping — the signal that a "clean" split still isn't reliable.
// Observed in the real SYB sample: "黑色+白色【純棉兩件裝】 簡約親膚" strips
@@ -29,8 +36,8 @@ type ParseResult struct {
Note string
}
// Parse implements the #41 rule: split on the last comma into color/size,
// strip 【...】 annotations, and classify the result. It never guesses a
// Parse implements the #41/#216 rule: split on the last comma, strip 【...】
// annotations, and classify the result. It never guesses a
// missing value and never invents a color or size that is not literally
// present in the input.
//
@@ -41,9 +48,13 @@ type ParseResult struct {
// brackets stripped, becomes the size candidate; color stays empty. A
// single token with no separator cannot be split into two dimensions
// without guessing which one it is.
// - comma present, last segment (size) and first segment(s) (color) both
// non-empty after stripping brackets, and the color candidate has no
// leftover separator/whitespace -> success.
// - comma present and exactly one side has an explicit size signal -> that
// side is size and the other side is color. This supports both observed
// SYB orders without allowing AI or a fuzzy color dictionary to swap roles.
// - comma present and neither side has an explicit size signal -> retain the
// established SYB color,size contract for backward compatibility.
// - comma present and both sides have explicit size signals -> uncertain;
// there is no safe color decision.
// - comma present but either side is empty after stripping, or the color
// candidate still carries a leftover '+' or internal whitespace -> the
// split happened but is not trustworthy -> uncertain.
@@ -65,12 +76,20 @@ func Parse(raw string) ParseResult {
return ParseResult{Size: size, Status: models.SYBParseStatusUncertain, Note: "无分隔符,仅识别出单一规格值"}
}
colorPart := stripBrackets(trimmed[:lastComma])
sizePart := stripBrackets(trimmed[lastComma+1:])
firstPart := stripBrackets(trimmed[:lastComma])
secondPart := stripBrackets(trimmed[lastComma+1:])
colorPart, sizePart := firstPart, secondPart
if colorPart == "" || sizePart == "" {
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "按逗号拆分后有一侧为空"}
}
firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart)
if firstIsSize && secondIsSize {
return ParseResult{Color: firstPart, Size: secondPart, Status: models.SYBParseStatusUncertain, Note: "逗号两侧均具有尺码特征,无法安全识别颜色"}
}
if firstIsSize {
colorPart, sizePart = secondPart, firstPart
}
if ambiguousPattern.MatchString(colorPart) {
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "颜色部分含备注文本或多个分隔符,拆分结果可能不准确"}
}
+21
View File
@@ -19,6 +19,27 @@ func TestParseRealSample_CleanColorAndSize(t *testing.T) {
}
}
func TestParseReversedSizeAndColorWhenOnlyFirstHasExplicitSizeEvidence(t *testing.T) {
result := sybimport.Parse("均碼,黑色")
if result.Color != "黑色" || result.Size != "均碼" || result.Status != models.SYBParseStatusSuccess {
t.Fatalf("expected reversed role parse 黑色/均碼 success, got %+v", result)
}
}
func TestParseKeepsTraditionalColorAndSizeOrderWhenSecondHasSizeEvidence(t *testing.T) {
result := sybimport.Parse("黑色,均码")
if result.Color != "黑色" || result.Size != "均码" || result.Status != models.SYBParseStatusSuccess {
t.Fatalf("expected normal role parse 黑色/均码 success, got %+v", result)
}
}
func TestParseTwoExplicitSizesIsUncertainInsteadOfSwapping(t *testing.T) {
result := sybimport.Parse("M,L")
if result.Status != models.SYBParseStatusUncertain || result.Color != "M" || result.Size != "L" {
t.Fatalf("expected conflicting size evidence to stay uncertain, got %+v", result)
}
}
func TestParseRealSample_AmbiguousColorWithAnnotationAndSeparator(t *testing.T) {
result := sybimport.Parse("黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】")
if result.Status != models.SYBParseStatusUncertain {
+164
View File
@@ -0,0 +1,164 @@
package sybimport
import (
"context"
"encoding/json"
"sort"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"gorm.io/gorm"
)
// SpecRoleRepairReport contains only aggregate migration evidence. It does not
// expose order numbers, raw source payloads, or specification text.
type SpecRoleRepairReport struct {
SYBRows int
ShopeeProducts int
}
type specRoleChange struct {
OldColor string
OldSize string
}
// RepairReversedSpecRoles corrects only the historical shape that #216 can
// prove was role-reversed: the newly parsed color/size are exactly the old
// size/color. Human-confirmed rows are never overwritten. RawJSON is read for
// reparsing and deliberately excluded from every update.
func RepairReversedSpecRoles(ctx context.Context, db *gorm.DB) (SpecRoleRepairReport, error) {
report := SpecRoleRepairReport{}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var records []models.SYBProduct
if err := tx.Where("manually_confirmed = ?", false).Find(&records).Error; err != nil {
return err
}
changesByProduct := map[uint64][]specRoleChange{}
for _, record := range records {
var raw rawDetailSpec
if json.Unmarshal([]byte(record.RawJSON), &raw) != nil {
continue
}
parsed := Parse(raw.ProductSpec)
if parsed.Status == models.SYBParseStatusFailed || parsed.Color == record.TargetColor ||
parsed.Color != record.TargetSize || parsed.Size != record.TargetColor {
continue
}
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(map[string]any{
"target_color": parsed.Color,
"target_size": parsed.Size,
"parse_status": parsed.Status,
"parse_note": parsed.Note,
}).Error; err != nil {
return err
}
report.SYBRows++
if record.ShopeeProductID != nil {
productID := *record.ShopeeProductID
changesByProduct[productID] = append(changesByProduct[productID], specRoleChange{
OldColor: record.TargetColor,
OldSize: record.TargetSize,
})
}
}
for productID, changes := range changesByProduct {
changed, err := reconcileRepairedShopeeSpecs(tx, productID, changes)
if err != nil {
return err
}
if changed {
report.ShopeeProducts++
}
}
return nil
})
return report, err
}
func reconcileRepairedShopeeSpecs(tx *gorm.DB, productID uint64, changes []specRoleChange) (bool, error) {
var product models.ShopeeProduct
if err := tx.First(&product, productID).Error; err != nil {
return false, err
}
existing, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
return false, err
}
var rows []models.SYBProduct
if err := tx.Where("shopee_product_id = ? AND (parse_status = ? OR manually_confirmed = ?)",
productID, models.SYBParseStatusSuccess, true).Find(&rows).Error; err != nil {
return false, err
}
usedColors, usedSizes := map[string]bool{}, map[string]bool{}
for _, row := range rows {
if row.TargetColor != "" {
usedColors[row.TargetColor] = true
}
if row.TargetSize != "" {
usedSizes[row.TargetSize] = true
}
}
oldColors, oldSizes := map[string]bool{}, map[string]bool{}
for _, change := range changes {
oldColors[change.OldColor] = true
oldSizes[change.OldSize] = true
}
cleaned := make([]shopeeproduct.SpecDimension, 0, len(existing))
for _, dimension := range existing {
values := make([]shopeeproduct.SpecValue, 0, len(dimension.Values))
for _, value := range dimension.Values {
remove := value.Source == shopeeproduct.ValueSourceImport && value.Mapping == nil
switch dimension.Role {
case shopeeproduct.RoleColor:
remove = remove && oldColors[value.Name] && !usedColors[value.Name]
case shopeeproduct.RoleSize:
remove = remove && oldSizes[value.Name] && !usedSizes[value.Name]
default:
remove = false
}
if !remove {
values = append(values, value)
}
}
dimension.Values = values
cleaned = append(cleaned, dimension)
}
incoming := make([]shopeeproduct.SpecDimension, 0, 2)
if values := sortedImportValues(usedColors); len(values) > 0 {
incoming = append(incoming, shopeeproduct.SpecDimension{Name: "颜色", Role: shopeeproduct.RoleColor, Values: values})
}
if values := sortedImportValues(usedSizes); len(values) > 0 {
incoming = append(incoming, shopeeproduct.SpecDimension{Name: "尺码", Role: shopeeproduct.RoleSize, Values: values})
}
reconciled := shopeeproduct.Merge(cleaned, incoming)
if err := shopeeproduct.Validate(reconciled); err != nil {
return false, err
}
encoded, err := shopeeproduct.Marshal(reconciled)
if err != nil {
return false, err
}
if encoded == product.SpecsJSON {
return false, nil
}
return true, tx.Model(&models.ShopeeProduct{}).Where("id = ?", productID).Update("specs_json", encoded).Error
}
func sortedImportValues(set map[string]bool) []shopeeproduct.SpecValue {
names := make([]string, 0, len(set))
for name := range set {
names = append(names, name)
}
sort.Strings(names)
values := make([]shopeeproduct.SpecValue, 0, len(names))
for _, name := range names {
values = append(values, shopeeproduct.SpecValue{Name: name, Source: shopeeproduct.ValueSourceImport})
}
return values
}
@@ -0,0 +1,126 @@
package sybimport
import (
"context"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
)
func TestRepairReversedSpecRolesIsSafeAndIdempotent(t *testing.T) {
db := newSyncTestDB(t)
manualMapping := &shopeeproduct.Mapping{PDDValue: "藏青", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{
{Name: "均碼", Source: shopeeproduct.ValueSourceImport},
{Name: "手工蓝", Source: shopeeproduct.ValueSourceManual, Mapping: manualMapping},
}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport}}},
})
if err != nil {
t.Fatal(err)
}
product := models.ShopeeProduct{ShopeeItemID: "ROLE-REPAIR", Title: "测试", ShopName: "测试店铺", Currency: "TWD", SpecsJSON: specs}
if err := db.Create(&product).Error; err != nil {
t.Fatal(err)
}
raw := `{"productSpec":"均碼,黑色"}`
row := models.SYBProduct{OrderCode: "ROLE-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: product.ShopeeItemID,
ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "均碼", TargetSize: "黑色", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: raw}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
manual := models.SYBProduct{OrderCode: "MANUAL-ORDER", DetailID: 2, StockID: 2, ShopeeItemID: product.ShopeeItemID,
ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "人工颜色", TargetSize: "人工尺码", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, ManuallyConfirmed: true, RawJSON: raw}
if err := db.Create(&manual).Error; err != nil {
t.Fatal(err)
}
report, err := RepairReversedSpecRoles(context.Background(), db)
if err != nil || report.SYBRows != 1 || report.ShopeeProducts != 1 {
t.Fatalf("unexpected repair report=%+v err=%v", report, err)
}
var repaired, untouched models.SYBProduct
db.First(&repaired, row.ID)
db.First(&untouched, manual.ID)
if repaired.TargetColor != "黑色" || repaired.TargetSize != "均碼" || repaired.RawJSON != raw {
t.Fatalf("repaired row is invalid: %+v", repaired)
}
if untouched.TargetColor != "人工颜色" || untouched.TargetSize != "人工尺码" {
t.Fatalf("manual row was overwritten: %+v", untouched)
}
db.First(&product, product.ID)
reconciled, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
t.Fatal(err)
}
assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "均碼", false)
assertSpecValue(t, reconciled, shopeeproduct.RoleSize, "黑色", false)
assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "黑色", true)
assertSpecValue(t, reconciled, shopeeproduct.RoleSize, "均碼", true)
assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "手工蓝", true)
replay, err := RepairReversedSpecRoles(context.Background(), db)
if err != nil || replay.SYBRows != 0 || replay.ShopeeProducts != 0 {
t.Fatalf("repair was not idempotent: report=%+v err=%v", replay, err)
}
}
func TestRepairReversedSpecRolesKeepsAmbiguousColorUncertain(t *testing.T) {
db := newSyncTestDB(t)
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "均碼", Source: shopeeproduct.ValueSourceImport}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "黑色+白色", Source: shopeeproduct.ValueSourceImport}}},
})
if err != nil {
t.Fatal(err)
}
product := models.ShopeeProduct{ShopeeItemID: "ROLE-UNCERTAIN", Title: "测试", ShopName: "测试店铺", Currency: "TWD", SpecsJSON: specs}
if err := db.Create(&product).Error; err != nil {
t.Fatal(err)
}
row := models.SYBProduct{OrderCode: "UNCERTAIN-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: product.ShopeeItemID,
ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "均碼", TargetSize: "黑色+白色", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"productSpec":"均碼,黑色+白色"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
report, err := RepairReversedSpecRoles(context.Background(), db)
if err != nil || report.SYBRows != 1 || report.ShopeeProducts != 1 {
t.Fatalf("unexpected repair report=%+v err=%v", report, err)
}
db.First(&row, row.ID)
if row.TargetColor != "黑色+白色" || row.TargetSize != "均碼" || row.ParseStatus != models.SYBParseStatusUncertain {
t.Fatalf("ambiguous color role/status was not preserved safely: %+v", row)
}
db.First(&product, product.ID)
reconciled, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
t.Fatal(err)
}
assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "均碼", false)
assertSpecValue(t, reconciled, shopeeproduct.RoleSize, "黑色+白色", false)
}
func assertSpecValue(t *testing.T, specs []shopeeproduct.SpecDimension, role, name string, want bool) {
t.Helper()
found := false
for _, dimension := range specs {
if dimension.Role != role {
continue
}
for _, value := range dimension.Values {
if value.Name == name {
found = true
}
}
}
if found != want {
t.Fatalf("role=%s value=%q found=%v want=%v specs=%+v", role, name, found, want, specs)
}
}
+31 -13
View File
@@ -59,12 +59,16 @@ type SyncReport struct {
// it a renamed or newly opened shop is never noticed (#50).
ShopBreakdown map[string]ShopBreakdown `json:"shopBreakdown"`
ShopFilterHash string `json:"shopFilterHash"`
OrderCount int `json:"orderCount"`
DetailCount int `json:"detailCount"`
Created int `json:"created"`
Updated int `json:"updated"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
// ShopFilterSnapshot is the immutable enabled-shop snapshot supplied when
// the run was created. It is intentionally a display-name list for audit;
// matching continues to use the normalized map passed to the sync.
ShopFilterSnapshot []string `json:"shopFilterSnapshot"`
OrderCount int `json:"orderCount"`
DetailCount int `json:"detailCount"`
Created int `json:"created"`
Updated int `json:"updated"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
}
type ShopBreakdown struct {
@@ -120,19 +124,24 @@ func Sync(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncCo
}
func SyncWithProgress(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string, progress ProgressFunc) (SyncReport, error) {
allowed, err := sybshop.EnabledNames(ctx, db)
if err != nil {
return SyncReport{From: dateFrom, To: dateTo, StartedAt: time.Now().UTC(), ShopBreakdown: map[string]ShopBreakdown{}}, err
}
return SyncWithShopSnapshot(ctx, db, client, cfg, dateFrom, dateTo, allowed, progress)
}
// SyncWithShopSnapshot runs against the immutable enabled-shop snapshot taken
// by StartImport. It must not query syb_shop again: doing so would make the
// run's effective allow-list differ from the snapshot recorded on its row.
func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string, allowed map[string]string, progress ProgressFunc) (SyncReport, error) {
report := SyncReport{From: dateFrom, To: dateTo, StartedAt: time.Now().UTC(),
ShopBreakdown: map[string]ShopBreakdown{}}
ShopBreakdown: map[string]ShopBreakdown{}, ShopFilterSnapshot: shopSnapshotNames(allowed)}
dates, err := splitDateRange(dateFrom, dateTo)
if err != nil {
return report, err
}
// `[必须]` The allow-list is read once and used for the whole run, so a shop
// enabled midway cannot make one day behave differently from the next.
allowed, err := sybshop.EnabledNames(ctx, db)
if err != nil {
return report, err
}
if len(allowed) == 0 {
return report, ErrNoEnabledShop
}
@@ -283,6 +292,15 @@ func SyncWithProgress(ctx context.Context, db *gorm.DB, client *sybclient.Client
return report, nil
}
func shopSnapshotNames(allowed map[string]string) []string {
names := make([]string, 0, len(allowed))
for _, display := range allowed {
names = append(names, display)
}
sort.Strings(names)
return names
}
func enabledShopHash(allowed map[string]string) string {
names := make([]string, 0, len(allowed))
for normalized := range allowed {
+57 -10
View File
@@ -28,6 +28,7 @@ func NewSyncRunService(db *gorm.DB) *SyncRunService { return &SyncRunService{db:
type CreateSyncRunInput struct {
DateFrom, DateTo, ShopFilterHash string
ShopFilterSnapshot map[string]string
OperatorID uint64
OperatorName string
}
@@ -38,10 +39,14 @@ func (s *SyncRunService) Create(ctx context.Context, input CreateSyncRunInput) (
return nil, invalidRequest(err.Error())
}
slot := uint8(1)
payload, err := marshalSyncRunPayload(nil, shopSnapshotNames(input.ShopFilterSnapshot))
if err != nil {
return nil, &ServiceError{Code: CodeSyncRunCreateFailed, Message: "服务端处理失败", Cause: err}
}
run := &models.SYBSyncRun{
DateFrom: input.DateFrom, DateTo: input.DateTo, Status: SyncRunRunning,
ActiveSlot: &slot, DaysTotal: len(dates), ShopFilterHash: input.ShopFilterHash,
ShopBreakdownJSON: "{}", OperatorID: input.OperatorID,
ShopBreakdownJSON: payload, OperatorID: input.OperatorID,
OperatorName: strings.TrimSpace(input.OperatorName), StartedAt: time.Now().UTC(),
}
if err := s.db.WithContext(ctx).Create(run).Error; err != nil {
@@ -55,7 +60,7 @@ func (s *SyncRunService) Create(ctx context.Context, input CreateSyncRunInput) (
}
func (s *SyncRunService) UpdateProgress(ctx context.Context, id uint64, progress SyncProgress) error {
breakdown, err := json.Marshal(progress.Report.ShopBreakdown)
payload, err := marshalSyncRunPayload(progress.Report.ShopBreakdown, progress.Report.ShopFilterSnapshot)
if err != nil {
return err
}
@@ -65,7 +70,7 @@ func (s *SyncRunService) UpdateProgress(ctx context.Context, id uint64, progress
"order_count": progress.Report.OrderCount, "detail_count": progress.Report.DetailCount,
"accepted_count": progress.Report.AcceptedCount, "shop_skipped": progress.Report.ShopSkipped,
"created_count": progress.Report.Created, "updated_count": progress.Report.Updated,
"shop_filter_hash": progress.Report.ShopFilterHash, "shop_breakdown_json": string(breakdown),
"shop_filter_hash": progress.Report.ShopFilterHash, "shop_breakdown_json": payload,
}).Error
}
@@ -73,7 +78,7 @@ func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, r
if status != SyncRunSucceeded && status != SyncRunFailed {
return fmt.Errorf("invalid terminal sync status %q", status)
}
breakdown, err := json.Marshal(report.ShopBreakdown)
payload, err := marshalSyncRunPayload(report.ShopBreakdown, report.ShopFilterSnapshot)
if err != nil {
return err
}
@@ -87,7 +92,7 @@ func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, r
"order_count": report.OrderCount, "detail_count": report.DetailCount,
"accepted_count": report.AcceptedCount, "shop_skipped": report.ShopSkipped,
"created_count": report.Created, "updated_count": report.Updated,
"shop_breakdown_json": string(breakdown),
"shop_breakdown_json": payload,
}
if report.ShopFilterHash != "" {
updates["shop_filter_hash"] = report.ShopFilterHash
@@ -146,8 +151,10 @@ type ShopBreakdownItem struct {
}
type SyncRunDTO struct {
models.SYBSyncRun
ShopBreakdown []ShopBreakdownItem `json:"shopBreakdown"`
ProgressPercent int `json:"progressPercent"`
ShopBreakdown []ShopBreakdownItem `json:"shopBreakdown"`
ShopFilterSnapshot []string `json:"shopFilterSnapshot"`
ShopFilterSnapshotPresent bool `json:"shopFilterSnapshotPresent"`
ProgressPercent int `json:"progressPercent"`
}
func (s *SyncRunService) List(ctx context.Context, request SyncRunListRequest) (SyncRunListResponse, error) {
@@ -188,8 +195,7 @@ func (s *SyncRunService) Detail(ctx context.Context, id uint64) (SyncRunDTO, err
}
func syncRunDTO(row models.SYBSyncRun) SyncRunDTO {
var values map[string]ShopBreakdown
_ = json.Unmarshal([]byte(row.ShopBreakdownJSON), &values)
values, snapshot, snapshotPresent := parseSyncRunPayload(row.ShopBreakdownJSON)
names := make([]string, 0, len(values))
for name := range values {
names = append(names, name)
@@ -210,7 +216,48 @@ func syncRunDTO(row models.SYBSyncRun) SyncRunDTO {
if row.Status == SyncRunSucceeded {
percent = 100
}
return SyncRunDTO{SYBSyncRun: row, ShopBreakdown: items, ProgressPercent: percent}
return SyncRunDTO{SYBSyncRun: row, ShopBreakdown: items, ShopFilterSnapshot: snapshot,
ShopFilterSnapshotPresent: snapshotPresent, ProgressPercent: percent}
}
const syncRunPayloadVersion = 1
type syncRunPayload struct {
Version int `json:"version"`
ShopBreakdown map[string]ShopBreakdown `json:"shopBreakdown"`
ShopFilterSnapshot []string `json:"shopFilterSnapshot"`
}
func marshalSyncRunPayload(breakdown map[string]ShopBreakdown, snapshot []string) (string, error) {
if breakdown == nil {
breakdown = map[string]ShopBreakdown{}
}
if snapshot == nil {
snapshot = []string{}
}
encoded, err := json.Marshal(syncRunPayload{
Version: syncRunPayloadVersion, ShopBreakdown: breakdown, ShopFilterSnapshot: snapshot,
})
if err != nil {
return "", err
}
return string(encoded), nil
}
func parseSyncRunPayload(raw string) (map[string]ShopBreakdown, []string, bool) {
var envelope syncRunPayload
if err := json.Unmarshal([]byte(raw), &envelope); err == nil && envelope.Version == syncRunPayloadVersion {
if envelope.ShopBreakdown == nil {
envelope.ShopBreakdown = map[string]ShopBreakdown{}
}
sort.Strings(envelope.ShopFilterSnapshot)
return envelope.ShopBreakdown, envelope.ShopFilterSnapshot, true
}
// Records written before #212 are a bare breakdown map. Preserve their
// existing statistics and state explicitly that no immutable snapshot exists.
values := map[string]ShopBreakdown{}
_ = json.Unmarshal([]byte(raw), &values)
return values, nil, false
}
func truncateRunError(value string, limit int) string {
@@ -4,6 +4,8 @@ import (
"context"
"strings"
"testing"
"go-admin/app/goauto/models"
)
func TestSyncRunSingleFlightAndTerminalRelease(t *testing.T) {
@@ -57,3 +59,32 @@ func TestSyncRunProgressDetailAndRecovery(t *testing.T) {
t.Fatalf("启动恢复状态不正确: %+v", detail)
}
}
func TestSyncRunPersistsImmutableShopSnapshotAndReadsLegacyBreakdown(t *testing.T) {
db := newSyncTestDB(t)
service := NewSyncRunService(db)
run, err := service.Create(context.Background(), CreateSyncRunInput{
DateFrom: "2026-08-01", DateTo: "2026-08-01", ShopFilterHash: "snapshot-hash",
ShopFilterSnapshot: map[string]string{"alpha": "Alpha 店", "beta": "Beta 店"},
})
if err != nil {
t.Fatal(err)
}
detail, err := service.Detail(context.Background(), run.ID)
if err != nil {
t.Fatal(err)
}
if !detail.ShopFilterSnapshotPresent || strings.Join(detail.ShopFilterSnapshot, ",") != "Alpha 店,Beta 店" {
t.Fatalf("新任务必须返回冻结快照: %+v", detail)
}
if err := db.Model(&models.SYBSyncRun{}).Where("id = ?", run.ID).Update("shop_breakdown_json", `{"旧店":{"accepted":2,"skipped":1}}`).Error; err != nil {
t.Fatal(err)
}
legacy, err := service.Detail(context.Background(), run.ID)
if err != nil {
t.Fatal(err)
}
if legacy.ShopFilterSnapshotPresent || len(legacy.ShopFilterSnapshot) != 0 || len(legacy.ShopBreakdown) != 1 || legacy.ShopBreakdown[0].Accepted != 2 {
t.Fatalf("旧记录必须保留统计且明确没有快照: %+v", legacy)
}
}
+20
View File
@@ -429,6 +429,26 @@ func TestSyncMatchesShopNamesThroughNormalization(t *testing.T) {
}
}
func TestSyncWithShopSnapshotDoesNotRereadChangedShopState(t *testing.T) {
db := newSyncTestDB(t)
allowed, err := sybshop.EnabledNames(context.Background(), db)
if err != nil {
t.Fatal(err)
}
if err := db.Model(&models.SYBShop{}).Where("normalized_name = ?", "测试店铺").Update("enabled", false).Error; err != nil {
t.Fatal(err)
}
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 2}, shopNames: []string{"测试店铺"}}
report, err := SyncWithShopSnapshot(context.Background(), db, newSyncClient(t, f),
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01", allowed, nil)
if err != nil {
t.Fatalf("冻结快照同步失败: %v", err)
}
if report.AcceptedCount != 2 || len(report.ShopFilterSnapshot) != 1 || report.ShopFilterSnapshot[0] != "测试店铺" {
t.Fatalf("后台不得重读已变化的店铺状态: %+v", report)
}
}
// 列表说属于启用店铺、明细却是另一家店时必须拦住。
func TestSyncRechecksShopOnDetailResponse(t *testing.T) {
db := newSyncTestDB(t)
+91
View File
@@ -0,0 +1,91 @@
package sybshop
import (
"context"
"fmt"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
// ReconcileNormalizedNames repairs the matching keys of live SYB shops created
// before Normalize became the single write-path rule. It changes neither the
// display name nor the enabled state. The operation is safe to repeat.
//
// A normalised-name collision is a data ambiguity. It must be resolved by an
// operator, rather than silently merging shops or choosing one arbitrarily.
func ReconcileNormalizedNames(ctx context.Context, db *gorm.DB) (int, error) {
if db == nil {
return 0, fmt.Errorf("syb shop normalized-name repair: database is nil")
}
updated := 0
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var shops []models.SYBShop
if err := tx.Order("id ASC").Find(&shops).Error; err != nil {
return err
}
desiredOwners := make(map[string]uint64, len(shops))
currentKeys := make(map[string]struct{}, len(shops))
type repair struct {
id uint64
desired string
temp string
}
repairs := make([]repair, 0)
for _, shop := range shops {
desired := Normalize(shop.DisplayName)
if desired == "" {
return fmt.Errorf("syb shop normalized-name repair: shop %d has blank display name", shop.ID)
}
if owner, exists := desiredOwners[desired]; exists && owner != shop.ID {
return fmt.Errorf("syb shop normalized-name repair: normalized key conflict between shops %d and %d", owner, shop.ID)
}
desiredOwners[desired] = shop.ID
currentKeys[shop.NormalizedName] = struct{}{}
if shop.NormalizedName != desired {
repairs = append(repairs, repair{id: shop.ID, desired: desired})
}
}
// Keys can be swapped (for example stale "a"/"b" values). Stage every
// repair through a unique temporary key so the unique index is preserved
// throughout the transaction.
reserved := make(map[string]struct{}, len(currentKeys)+len(desiredOwners))
for key := range currentKeys {
reserved[key] = struct{}{}
}
for key := range desiredOwners {
reserved[key] = struct{}{}
}
for index := range repairs {
base := fmt.Sprintf("__goauto_syb_shop_normalize_repair_%d__", repairs[index].id)
temp := base
for suffix := 1; ; suffix++ {
if _, exists := reserved[temp]; !exists {
break
}
temp = fmt.Sprintf("%s%d", base, suffix)
}
repairs[index].temp = temp
reserved[temp] = struct{}{}
}
for _, repair := range repairs {
if err := tx.Model(&models.SYBShop{}).Where("id = ?", repair.id).
UpdateColumn("normalized_name", repair.temp).Error; err != nil {
return err
}
}
for _, repair := range repairs {
if err := tx.Model(&models.SYBShop{}).Where("id = ?", repair.id).
UpdateColumn("normalized_name", repair.desired).Error; err != nil {
return err
}
}
updated = len(repairs)
return nil
})
return updated, err
}
@@ -0,0 +1,93 @@
package sybshop_test
import (
"context"
"strings"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybshop"
)
func TestReconcileNormalizedNamesRepairsLegacyKey(t *testing.T) {
db := openDB(t)
shop := models.SYBShop{DisplayName: "樂齡樂活美學館|銀髮居家熟齡悅己壯世代優雅生活", NormalizedName: "legacy-key", Enabled: true}
if err := db.Create(&shop).Error; err != nil {
t.Fatalf("创建历史店铺失败: %v", err)
}
updated, err := sybshop.ReconcileNormalizedNames(context.Background(), db)
if err != nil {
t.Fatalf("回填失败: %v", err)
}
if updated != 1 {
t.Fatalf("应修复 1 条,实际 %d", updated)
}
var reloaded models.SYBShop
if err := db.First(&reloaded, shop.ID).Error; err != nil {
t.Fatalf("读取修复结果失败: %v", err)
}
if reloaded.NormalizedName != sybshop.Normalize(shop.DisplayName) {
t.Fatalf("规范化键不正确: %q", reloaded.NormalizedName)
}
if reloaded.DisplayName != shop.DisplayName || !reloaded.Enabled {
t.Fatalf("回填不应修改展示名或启用状态: %+v", reloaded)
}
names, err := sybshop.EnabledNames(context.Background(), db)
if err != nil || names[sybshop.Normalize(shop.DisplayName)] != shop.DisplayName {
t.Fatalf("修复后启用快照未命中: names=%v err=%v", names, err)
}
updated, err = sybshop.ReconcileNormalizedNames(context.Background(), db)
if err != nil || updated != 0 {
t.Fatalf("重复回填应无变化: updated=%d err=%v", updated, err)
}
}
func TestReconcileNormalizedNamesRejectsCollisionWithoutWriting(t *testing.T) {
db := openDB(t)
first := models.SYBShop{DisplayName: "A店", NormalizedName: "legacy-one", Enabled: true}
second := models.SYBShop{DisplayName: "A店", NormalizedName: "legacy-two", Enabled: true}
if err := db.Create(&first).Error; err != nil {
t.Fatalf("创建第一条历史店铺失败: %v", err)
}
if err := db.Create(&second).Error; err != nil {
t.Fatalf("创建第二条历史店铺失败: %v", err)
}
updated, err := sybshop.ReconcileNormalizedNames(context.Background(), db)
if updated != 0 || err == nil || !strings.Contains(err.Error(), "normalized key conflict") {
t.Fatalf("冲突应失败且不报告更新: updated=%d err=%v", updated, err)
}
var rows []models.SYBShop
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
t.Fatalf("读取冲突结果失败: %v", err)
}
if len(rows) != 2 || rows[0].NormalizedName != "legacy-one" || rows[1].NormalizedName != "legacy-two" {
t.Fatalf("冲突时不应修改任何键: %+v", rows)
}
}
func TestReconcileNormalizedNamesHandlesSwappedLegacyKeys(t *testing.T) {
db := openDB(t)
first := models.SYBShop{DisplayName: "A", NormalizedName: "b", Enabled: true}
second := models.SYBShop{DisplayName: "B", NormalizedName: "a", Enabled: true}
if err := db.Create(&first).Error; err != nil {
t.Fatalf("创建第一条历史店铺失败: %v", err)
}
if err := db.Create(&second).Error; err != nil {
t.Fatalf("创建第二条历史店铺失败: %v", err)
}
updated, err := sybshop.ReconcileNormalizedNames(context.Background(), db)
if err != nil || updated != 2 {
t.Fatalf("交换键回填失败: updated=%d err=%v", updated, err)
}
var rows []models.SYBShop
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
t.Fatalf("读取回填结果失败: %v", err)
}
if rows[0].NormalizedName != "a" || rows[1].NormalizedName != "b" {
t.Fatalf("交换键未正确回填: %+v", rows)
}
}
+3 -3
View File
@@ -182,9 +182,9 @@ func ensureDeviceIdleForCurrentPage(tx *gorm.DB, deviceID uint64, now time.Time)
return serviceError(CodeDeviceBusy, "设备正在执行任务,请稍后再试")
}
if err := tx.Model(&models.PurchaseTask{}).
Where("device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", deviceID,
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted},
[]string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).
Where("device_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", deviceID,
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending},
models.PurchaseTaskStatusPending, now).
Count(&busy).Error; err != nil {
return internalError(err)
}
+3 -3
View File
@@ -246,9 +246,9 @@ func ensureDeviceIdleForReset(tx *gorm.DB, deviceID, taskID uint64, now time.Tim
return serviceError(CodeDeviceBusy, "设备正在执行其他任务")
}
if err := tx.Model(&models.PurchaseTask{}).
Where("device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", deviceID,
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted},
[]string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).
Where("device_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", deviceID,
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending},
models.PurchaseTaskStatusPending, now).
Count(&busy).Error; err != nil {
return internalError(err)
}
@@ -0,0 +1,32 @@
package version_local
import (
"context"
"runtime"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/app/goauto/sybshop"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
// This migration repairs historical syb_shop.normalized_name values written
// before all shop-name write paths used sybshop.Normalize (#213).
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBShopNormalizedNameBackfill)
}
func migrateSYBShopNormalizedNameBackfill(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := goautomigrations.Migrate(tx); err != nil {
return err
}
if _, err := sybshop.ReconcileNormalizedNames(context.Background(), tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,51 @@
package version_local
import (
"testing"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestMigrateSYBShopNormalizedNameBackfillRepairsLegacyRows(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("打开测试数据库失败: %v", err)
}
if err := db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatalf("创建迁移记录表失败: %v", err)
}
if err := goautomigrations.Migrate(db); err != nil {
t.Fatalf("创建 GoAuto 表失败: %v", err)
}
shop := models.SYBShop{DisplayName: "ABC店", NormalizedName: "ABC店", Enabled: true}
if err := db.Create(&shop).Error; err != nil {
t.Fatalf("创建历史店铺失败: %v", err)
}
const version = "1787983900000"
if err := migrateSYBShopNormalizedNameBackfill(db, version); err != nil {
t.Fatalf("执行回填迁移失败: %v", err)
}
var reloaded models.SYBShop
if err := db.First(&reloaded, shop.ID).Error; err != nil {
t.Fatalf("读取回填结果失败: %v", err)
}
if reloaded.NormalizedName != "abc店" {
t.Fatalf("迁移未回填规范化键: %q", reloaded.NormalizedName)
}
var records int64
if err := db.Model(&common.Migration{}).Where("version = ?", version).Count(&records).Error; err != nil {
t.Fatalf("读取迁移记录失败: %v", err)
}
if records != 1 {
t.Fatalf("迁移应留下一个版本记录,实际 %d", records)
}
}
@@ -0,0 +1,34 @@
package version_local
import (
"context"
"runtime"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/app/goauto/sybimport"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
// This migration repairs SYB rows whose two specification roles were stored
// in reverse before #216 recognized both observed source orders. The domain
// repair also removes only now-unreferenced, unmapped import values from the
// corresponding Shopee archive; human facts and raw source JSON are retained.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBSpecRoleOrderRepair)
}
func migrateSYBSpecRoleOrderRepair(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := goautomigrations.Migrate(tx); err != nil {
return err
}
if _, err := sybimport.RepairReversedSpecRoles(context.Background(), tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,62 @@
package version_local
import (
"testing"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestMigrateSYBSpecRoleOrderRepair(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatal(err)
}
if err := goautomigrations.Migrate(db); err != nil {
t.Fatal(err)
}
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "均碼", Source: shopeeproduct.ValueSourceImport}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport}}},
})
if err != nil {
t.Fatal(err)
}
product := models.ShopeeProduct{ShopeeItemID: "MIGRATION-ROLE", Title: "测试", ShopName: "测试店铺", Currency: "TWD", SpecsJSON: specs}
if err := db.Create(&product).Error; err != nil {
t.Fatal(err)
}
row := models.SYBProduct{OrderCode: "MIGRATION-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: product.ShopeeItemID,
ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "均碼", TargetSize: "黑色", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"productSpec":"均碼,黑色"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
const version = "1787984000000"
if err := migrateSYBSpecRoleOrderRepair(db, version); err != nil {
t.Fatalf("执行规格角色修复迁移失败: %v", err)
}
if err := db.First(&row, row.ID).Error; err != nil {
t.Fatal(err)
}
if row.TargetColor != "黑色" || row.TargetSize != "均碼" {
t.Fatalf("迁移未修正规格角色: %q/%q", row.TargetColor, row.TargetSize)
}
var records int64
if err := db.Model(&common.Migration{}).Where("version = ?", version).Count(&records).Error; err != nil {
t.Fatal(err)
}
if records != 1 {
t.Fatalf("迁移应记录一次版本,实际 %d", records)
}
}
@@ -0,0 +1,30 @@
package version_local
import (
"context"
"runtime"
"go-admin/app/goauto/sybimport"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
// The first #216 local migration was exercised against live data before this
// follow-up case was known. Rerunning the idempotent repair lets already-
// migrated installations correct proven role reversals whose color remains
// uncertain, while fresh installations simply execute an empty second pass.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBSpecRoleOrderUncertainRepair)
}
func migrateSYBSpecRoleOrderUncertainRepair(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if _, err := sybimport.RepairReversedSpecRoles(context.Background(), tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}