Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d84f6ddf21 | ||
|
|
f40331189b | ||
|
|
2895f3d72d | ||
|
|
e4051ed8df | ||
|
|
5859a819c8 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 53
|
||||
versionName = "0.9.40"
|
||||
versionCode = 57
|
||||
versionName = "0.9.44"
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
+21
-5
@@ -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>()
|
||||
|
||||
+52
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+41
-6
@@ -83,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)
|
||||
@@ -101,6 +105,7 @@ class PurchaseRehearsalExecutor(
|
||||
null
|
||||
}
|
||||
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try {
|
||||
live.advanceToOrderConfirmation()
|
||||
addressProof = live.updateShippingAddress(input.addressSuffix)
|
||||
null
|
||||
} catch (error: PurchaseLiveException) {
|
||||
@@ -208,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) {
|
||||
@@ -229,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 -> "打开拼多多入口发生变化"
|
||||
@@ -242,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", "没有进入拼多多商品页面")
|
||||
}
|
||||
@@ -347,7 +371,10 @@ class PurchaseRehearsalExecutor(
|
||||
"规格入口手势目标不唯一 [${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(),
|
||||
)
|
||||
}
|
||||
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
|
||||
wait.failure?.let { return it }
|
||||
@@ -454,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,
|
||||
@@ -809,7 +842,9 @@ 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
|
||||
|
||||
+25
-2
@@ -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
|
||||
}
|
||||
|
||||
+114
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+78
-19
@@ -81,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
|
||||
@@ -404,8 +404,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())
|
||||
}
|
||||
@@ -416,10 +416,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)
|
||||
}
|
||||
|
||||
@@ -429,10 +429,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)
|
||||
}
|
||||
@@ -440,15 +440,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
|
||||
@@ -505,6 +540,20 @@ class PurchaseRehearsalExecutorTest {
|
||||
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>()
|
||||
@@ -523,8 +572,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("选择规格"))
|
||||
}
|
||||
@@ -551,7 +598,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
|
||||
@@ -882,6 +929,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,
|
||||
@@ -931,11 +980,16 @@ class PurchaseRehearsalExecutorTest {
|
||||
private var productEvidenceLost = false
|
||||
private var reviewPage = false
|
||||
private var pddCaptureCount = 0
|
||||
private var browserCaptureCount = 0
|
||||
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)
|
||||
@@ -945,6 +999,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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:29:41Z
|
||||
wiki_revision: 7468b9fbdd4d0bbbb9a73580c22ec868b3085753
|
||||
synchronized_at: 2026-09-05T07:16:39Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 项目档案
|
||||
@@ -21,16 +21,20 @@ synchronized_at: 2026-09-04T11:29:41Z
|
||||
| 后续设计范围 | 从 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
@@ -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: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:29:46Z
|
||||
wiki_revision: 62ddbe4469740c02ce4a6ca2fd1966a89a79322f
|
||||
synchronized_at: 2026-09-05T07:16:44Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 开发工作流
|
||||
|
||||
## 语言与术语
|
||||
|
||||
- 用户可以使用中文、英文或合理的中英混合语言交流;默认使用中文分析、回复、编写工单和维护内部项目文档。
|
||||
- 代码标识符、命令、参数、路径、文件名、API 名称、协议名、日志和错误原文保持原样;必要时补充简短中文解释。
|
||||
- 用户明确要求某次回复或交付物使用其他语言时,按该次要求执行,不改写接口契约或影响搜索和执行的原文。
|
||||
|
||||
## 事实来源
|
||||
|
||||
| 信息 | 唯一事实来源 |
|
||||
@@ -18,7 +24,7 @@ synchronized_at: 2026-09-04T11:29:46Z
|
||||
| 源码、迁移、测试、版本绑定分析、本地原型和核心 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-09-04T11:29:46Z
|
||||
- 每个核心页面写入后必须在线回读并取得 revision。页面缺失、回读失败或没有 revision 时停止初始化。
|
||||
- 产品编码前运行 `python dev_scripts/harness.py sync --verify`;全部成功才表示初始化完成。
|
||||
|
||||
## 项目治理模式与不可裁剪底线
|
||||
|
||||
GoAuto 默认采用轻量治理。文案、注释、格式、局部样式或布局、预期行为明确的小 Bug,以及不改变接口、数据结构、权限和安全边界的单模块低风险调整可以直接实施,无需为了留痕补建工单。完整独立需求、新页面、跨模块功能,以及 API、数据结构、权限、安全、迁移或范围不明确的变化必须建单。
|
||||
|
||||
采购、创建订单、真实个人或生产数据、权限、安全、并发、数据库迁移、删除数据、发布和其他不可逆操作始终升级为高风险。无论任务采用何种最小门禁,凭据保护、永久禁止付款、个人与生产数据最小化、设备互斥、Agent 禁止 OCR/VLM、控件树与整屏截图禁存、工作区保护、真实测试和人工验收均不可裁剪。
|
||||
|
||||
### 明确授权后的执行
|
||||
|
||||
- 当前聊天中用户给出的明确指令,或 Gitea 工单中能够归属于有权人工的明确授权,可以作为执行依据,不要求把同一授权重复复制到工单后再次确认。
|
||||
- 授权必须能识别操作、对象和范围;Agent 自动生成的工单、草稿、摘要或对用户意图的转述不能单独构成人工授权。
|
||||
- 获得有效授权后,只核对准确目标、授权范围和当前状态等最小必要前提,不得仅因操作不可逆而重复询问或拒绝。
|
||||
- 授权不自动覆盖相邻对象或后续任务;环境、对象、范围或影响发生实质变化时重新确认。平台自身强制的审批、安全策略或权限限制继续有效。
|
||||
|
||||
## 工单与设计证据双门禁
|
||||
|
||||
正式实施前先判断是否需要工单,再判断需要什么设计证据。工单不能替代原型确认,原型也不能替代技术方案、安全检查和单元工单。
|
||||
|
||||
@@ -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: bd2748f2b58daee92eaa1e5ed16ce61fa18f728f
|
||||
synchronized_at: 2026-09-05T01:00:01Z
|
||||
wiki_revision: 21ad1681d12a40e7042567f03716d899d49b737e
|
||||
synchronized_at: 2026-09-05T07:16:58Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -144,7 +144,7 @@ synchronized_at: 2026-09-05T01:00:01Z
|
||||
- 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 订单号为准。
|
||||
@@ -341,7 +341,7 @@ synchronized_at: 2026-09-05T01:00:01Z
|
||||
|
||||
## SYB 采购强制当次规格探测(#215)
|
||||
|
||||
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。已有长期映射只作商品档案事实,不直接进入任务执行规格。
|
||||
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务;第二趟复用 PDD 当前页,不再次打开链接,也不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
|
||||
- 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。
|
||||
- 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。
|
||||
|
||||
@@ -429,3 +429,5 @@ synchronized_at: 2026-09-05T01:00:01Z
|
||||
- 管理员可在设备管理对未停用设备发起“重置设备身份”。此动作不删除设备记录、不改变设备 ID、能力或已绑定的待领取任务;它立即使旧 Token 无效,并只生成一次、有效期 10 分钟的恢复码。
|
||||
- 恢复码仅显示给发起操作的管理员一次,服务端只保存摘要;不得进入列表、日志、任务记录、Android 持久化或普通接口。手机操作员必须在同一安装实例的 Agent 设置中手动输入。
|
||||
- 服务端只接受同一 `installId`、未过期且尚未使用的恢复码完成重新注册,成功后签发新 Token 并使恢复码失效。过期、重复使用、installId 不符或停用均明确失败;不能通过清空数据、直接改库或“吊销 Token”恢复原任务归属。
|
||||
|
||||
- 自 #223 起,任务创建时把与冻结 SYB 目标对应的已确认商品规格映射保存为不可变的探测指导快照,但仍不得跳过首趟真机探测。探测完成后,每个角色先验证快照映射能否按既有规范化规则唯一对应当次候选,能对应时固化当次候选原文;不能对应时只对该未解决角色执行确定性匹配,仍无结果才调用 AI。已解决角色不重复交给 AI,任一最终值仍必须逐字属于当次候选;历史映射失效、规范化后歧义或角色不符时不得复用。候选完整但无法决策时提示“已采集到当前规格,但未能确定颜色或尺码映射”,不再误报候选不存在。
|
||||
|
||||
@@ -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: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:02Z
|
||||
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,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: 644b864833730130cf4a2098a01611da0c0ac45d
|
||||
synchronized_at: 2026-09-05T01:01:14Z
|
||||
wiki_revision: e5a443e16e288143c2c890c674038c531abe3f16
|
||||
synchronized_at: 2026-09-05T07:17:37Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -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` | 不可逆标记已落库,只能核单,禁止再次点击 | 是 |
|
||||
@@ -529,15 +529,15 @@ 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` | 创建订单前先落不可逆标记;演练任务和 `spec_probe` attempt 永远拒绝 |
|
||||
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 |
|
||||
|
||||
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 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 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放设备与已知账号租约并进入 `spec_probe_pending`;服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
|
||||
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放数据库租约和已知账号运行守卫并进入 `spec_probe_pending`,但服务端调度与 Agent 必须把当前设备保留给同一采购流程:`next` 返回该等待任务,Agent 只轮询等待,不领取其他采购或采集任务。服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 复用首趟仍打开的页面,只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
|
||||
|
||||
任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。历史兼容的就地 `/reset` 保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;映射不完整时必须拒绝,不能进入正式采购阶段。普通 Agent 重试创建新任务并重新取得一次探测资格。Android 不得根据映射是否非空、错误文字或本地判断扩大资格。
|
||||
|
||||
@@ -865,3 +865,5 @@ 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 决策权限。
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestParseBuiltAgentAPKWhenAvailable(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata.VersionCode != 53 || metadata.VersionName != "0.9.40" {
|
||||
if metadata.VersionCode != 54 || metadata.VersionName != "0.9.41" {
|
||||
t.Fatalf("metadata=%+v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -537,8 +550,8 @@ 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 {
|
||||
matched, matchErr := s.matcher().Resolve(ctx, request)
|
||||
valid := matchErr == nil && (matched.Source == aimatching.SourceExact || matched.Source == aimatching.SourceAI) &&
|
||||
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 {
|
||||
@@ -577,7 +590,7 @@ func purchaseMatchReason(err error) string {
|
||||
return "AI 规格匹配暂时不可用,请稍后重新创建采购任务"
|
||||
}
|
||||
}
|
||||
return "没有找到可采购的 PDD 颜色或尺码"
|
||||
return "已采集到当前规格,但未能确定颜色或尺码映射"
|
||||
}
|
||||
|
||||
type probedSpecCandidates struct {
|
||||
@@ -659,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 {
|
||||
@@ -678,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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -281,9 +281,14 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
currency = shopee.Currency
|
||||
}
|
||||
// Every new SYB purchase must use the candidates observed on the
|
||||
// current PDD page. Persisted mappings remain product-level history,
|
||||
// but they cannot skip this task's read-only probe phase.
|
||||
// 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}
|
||||
@@ -293,7 +298,7 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
} 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, decisionSnapshot = "", "", "unresolved", "{}"
|
||||
mappedColor, mappedSize, specSource = "", "", "unresolved"
|
||||
} else if pdd.Status != "active" {
|
||||
mappedColor, mappedSize, specSource = "", "", "unresolved"
|
||||
} else if specSource == "manual_mapping" || specSource == "exact_match" || specSource == "ai_match" {
|
||||
|
||||
@@ -31,13 +31,15 @@ type fixture struct {
|
||||
}
|
||||
|
||||
type liveProbeMatcher struct {
|
||||
result aimatching.MatchResult
|
||||
err error
|
||||
calls int
|
||||
result aimatching.MatchResult
|
||||
err error
|
||||
calls int
|
||||
request aimatching.MatchRequest
|
||||
}
|
||||
|
||||
func (matcher *liveProbeMatcher) Resolve(context.Context, aimatching.MatchRequest) (aimatching.MatchResult, error) {
|
||||
func (matcher *liveProbeMatcher) Resolve(_ context.Context, request aimatching.MatchRequest) (aimatching.MatchResult, error) {
|
||||
matcher.calls++
|
||||
matcher.request = request
|
||||
return matcher.result, matcher.err
|
||||
}
|
||||
|
||||
@@ -422,10 +424,109 @@ func TestLiveProbeCallsAIOnlyAfterCandidatesAreReturned(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
request := aimatching.MatchRequest{TargetColor: "黑色", TargetSize: "XL", Colors: []string{"黑色"}, Sizes: []string{"XL"}}
|
||||
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
|
||||
@@ -449,6 +550,42 @@ func TestLiveProbeRejectsMatcherValueOutsideCurrentCandidates(t *testing.T) {
|
||||
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) {
|
||||
@@ -625,6 +762,42 @@ func TestCreateDispatchesProbeInsteadOfExternalArchiveMatching(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user