Compare commits

..
Author SHA1 Message Date
QiuSW a16e6920b7 docs(#277): record sanitized pdd home evidence 2026-09-15 10:07:27 +08:00
QiuSW def0a86f63 fix(#277): fail image search tasks explicitly until automation is available 2026-09-15 09:59:56 +08:00
QiuSW ba7d2a6b72 feat(#280): remember image search device 2026-09-15 09:57:17 +08:00
QiuSW a3cf98940a fix(#279): only auto-link active pdd products 2026-09-15 09:51:39 +08:00
QiuSW 56706a0b45 fix(#280): show image search association marker 2026-09-15 09:45:50 +08:00
QiuSW a4874091a8 feat: prepare validated image search media asset (#277) 2026-09-15 09:42:25 +08:00
QiuSW 9e0dcc254e feat: parse image search task metadata in agent (#277) 2026-09-15 09:40:51 +08:00
QiuSW cda1978bc6 test: prevent image snapshot payload leakage (#278) 2026-09-15 09:37:01 +08:00
QiuSW c80d0c91d5 feat: add image search batch dialog to syb products (#280) 2026-09-15 09:07:45 +08:00
QiuSW be491c7692 feat: expose image search batch API to web (#280) 2026-09-15 09:02:08 +08:00
QiuSW 90c1668fe3 test: cover image search auto-link guards (#279) 2026-09-15 09:01:10 +08:00
QiuSW 804448ede0 feat: show image search link marker in shopee detail (#279) 2026-09-15 08:59:37 +08:00
QiuSW 5a7e219230 feat: add image-search linked filter to shopee list (#279) 2026-09-15 08:58:35 +08:00
QiuSW 36d1f82ce2 feat: filter image-search linked shopee products (#279) 2026-09-15 08:53:37 +08:00
QiuSW 1c998911be feat: auto link completed image search results (#279) 2026-09-15 08:50:19 +08:00
QiuSW 45437a9214 feat: record cross-currency image search price guard (#278) 2026-09-15 08:44:39 +08:00
QiuSW 42b5312198 feat: support image search identity handoff and safe payload (#278) 2026-09-15 08:42:11 +08:00
QiuSW 7b540f3163 feat: create and dispatch image search tasks (#278) 2026-09-15 08:41:42 +08:00
QiuSW ffa5fc0710 fix: add idempotent image snapshot column migration (#278) 2026-09-14 18:02:22 +08:00
QiuSW 66ab36c88c feat: expose image search snapshot in task payload (#278) 2026-09-14 16:49:35 +08:00
QiuSW 35b69fc25a fix: narrow image search migration (#278) 2026-09-14 16:31:07 +08:00
QiuSW b4dc7fc9ae feat: track image search links and preserve manual overrides (#279) 2026-09-14 16:08:55 +08:00
QiuSW baa9408a36 feat: allow image search collection task source (#278) 2026-09-14 16:05:01 +08:00
QiuSW e01fcd9860 feat: request media permission for image search (#276) 2026-09-14 16:03:21 +08:00
QiuSW be03972779 merge: #283 purchase readiness consistency 2026-09-14 14:48:13 +08:00
QiuSW c137f5072f fix: keep purchase readiness consistent with confirmed mappings (#283) 2026-09-14 14:42:11 +08:00
QiuSW 82b4ebd406 feat(ops): add batch SYB failed reparse script (#282) 2026-09-14 11:34:36 +08:00
QiuSW b83f20c6b7 fix(syb): accept descriptive color specs (#274) 2026-09-14 10:52:02 +08:00
QiuSW e0ed05d15a fix(syb): accept reliable single spec dimension (#274) 2026-09-12 12:01:15 +08:00
QiuSW 8163dd10eb fix(ai): match size by standard token (#273) 2026-09-12 11:46:46 +08:00
QiuSW aed295c8c1 fix(syb): reduce parse status to success or failed (#272) 2026-09-12 11:36:43 +08:00
QiuSWandClaude Opus 5 c14f5f528b fix(server): keep agent diagnostics on order-check failures #271
normalizeOrderUnknownFailure replaced the agent's message with a canonical
sentence, so the diagnostic #210 added never reached the database. Across the
61 PURCHASE_ORDER_PAYMENT_REPEATED failures on production between 2026-09-07
and 09-11, not one carried paymentBackAttempts or
consecutivePaymentSamplesAfterBack, which left three very different causes
indistinguishable: the Back never left the payment activity, the page rendered
slower than the ~1.1s window the agent allows, or the page was already the
order page and no marker matched. Those need different fixes, and there was no
way to tell which one to make.

The canonical sentence stays authoritative and leads the message — agent text
is free-form and the wording per error code has to stay stable — but the agent
evidence is now appended behind it, flattened to one line (control characters
and U+FEFF removed, whitespace collapsed) and truncated to the column's 1000
runes with the canonical part kept whole. The sibling "failed" branch already
stored agent text verbatim, so this adds no new trust assumption.

That branch also only wrote the failure onto the task, never the attempt, so
every attempt it produced carried a NULL error_code and was invisible to
attempt-level statistics — the spec-panel failures on production are exactly
that. It now writes both.

This restores observability only; it does not reduce how often
PAYMENT_REPEATED happens. Tuning the agent's sampling window is deliberately
left out until the next production failure shows real values.

Wiki updated first: Android-Agent-API-Contract@ba259178.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-12 11:13:02 +08:00
QiuSW 8293b27c19 fix(syb): retry drifting sync page once (#270) 2026-09-12 09:34:44 +08:00
QiuSWandClaude Opus 5 c24e8a4439 docs: refresh stale wiki mirrors picked up by the same sync run
Unrelated to #269. These three pages were changed online by other work and
the local mirror had drifted (02 and 03 from 2026-09-10, 06 from 2026-09-04).
The sync that mirrored the #269 pages regenerates every mapping, so it brought
these along; they are committed separately to keep the ticket's own commit
limited to its files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 17:15:38 +08:00
QiuSWandClaude Opus 5 40a254dc41 docs: mirror SYB product filter rules and admin API #269
Wiki updated online first, then mirrored:
- SYB-ERP-Interface-Contract@f1792b7f — filter order after shop admission,
  OR-not-AND with the 8498/6392/6386/8504 figures from the 2026-09-11
  production check, empty variationSku kept, per-rule hit counts, and the
  no-add/no-delete rule for structural criteria.
- Android-Agent-API-Contract@38869285 — the syb-product-filters endpoints,
  the server-side refusal of char add/delete, and the live lastHitCount
  requirement for the disable confirmation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 17:15:38 +08:00
QiuSWandClaude Opus 5 503b3cbbc5 fix(goauto): create syb_product_filter on existing databases #269
Three defects that only a real MySQL run could show.

The table had no version-local migration. Adding the model to
migrations.Migrate covers databases built from scratch, but every existing
one already has the older versions recorded in sys_migration, so that list
never runs again there. cmd/migrate's post-migration table check caught it:
"迁移后仍缺少表:syb_product_filter". The new version file creates the table
and seeds the same eight rules with FirstOrCreate on the normalized key, so
running both paths against one database cannot duplicate them.

Keyword and NormalizedKeyword were typed text, which MySQL refuses to put in
a key specification without a prefix length (Error 1170), so the unique index
could not be built at all. They are sized 200 now, matching how SYBShop sizes
its own matching key. SQLite accepts text in an index, which is why the whole
test suite passed while the real migration failed.

The 只看启用 filter is removed rather than kept: hiding a disabled structural
rule would conceal the one state on this page that matters most, since a
disabled "#" means roughly 65% of 档口 rows start entering the system again.
Keyword search stays.

Verified against the local MySQL database: migration applies, eight rules
present with traditional Chinese preserved (印花大學T normalizes to 印花大學t),
menu row created under 采采管理.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 17:00:11 +08:00
QiuSWandClaude Opus 5 cad9f6d185 fix(goauto): add the missing menu migration for SYB product filters #269
Registering a module in access.GoAutoModules() does not make its page
reachable on an existing database. sys_menu rows are written by the
versioned migration that first created them, and that version is already
recorded in sys_migration everywhere — it never runs again. Startup only
reconciles the casbin API matrix, not menus.

So on a fresh database the page appeared and on every real one it did not:
the table, the API and the route were all present with no way to navigate
there. This adds a migration that upserts the entry under 采采管理 beside
「SYB 店铺」 and binds it to both 管理员 and 采购员.

Verified by deleting the row from an already-migrated database and
re-running migrate: 新执行 1 个,跳过 42 个, row restored with both roles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 16:49:56 +08:00
QiuSWandClaude Opus 5 78df997fc3 test(goauto): pin SYB filter behaviour against real variationSku samples #269
The samples are the ones the production database actually holds: 档口 codes
like "新寮-雅伊阁大码女装#301" and "DD#004" must be filtered, while own
codes like "PDD0582", "my238", "1" and "引流款" must not, and an empty
variationSku is a product without a supplier code rather than a 档口 item.

The test also asserts the two structural rules record different hit counts.
That is the property the previous aggregate-per-kind write could not hold,
and the disable-confirmation dialog depends on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 16:37:43 +08:00
QiuSWandClaude Opus 5 20f59b68bd fix(goauto): record SYB filter hits per rule, not per kind #269
UpdateHits wrote one aggregate per kind, so every structural rule stored
the same number. The disable-confirmation dialog quotes that number
("停用「#」后…约 N 条明细会恢复入库"), and on real data "#" matches 8498
rows while "-" matches 6392 — the dialog would have warned with a figure
2100 rows off for one of them.

Match now returns the rule that matched instead of a char/keyword pair,
so the caller can attribute each hit to one rule, and UpdateHits writes
every rule's own count. Rules that matched nothing are written as 0 so a
previous run's number is never left behind.

The management page is reformatted to the repo's usual Vue layout and
gains the search / 只看启用 / 重置 controls the approved prototype shows.
Cancelling the disable confirmation now just reverts the switch instead
of throwing an unhandled rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 16:27:13 +08:00
QiuSW 652bf30f5a feat(goauto): add SYB product variation filters (#269) 2026-09-11 16:20:26 +08:00
QiuSW 79929036bb docs: clarify metadata-only partial collection eligibility #259 2026-09-10 16:10:13 +08:00
QiuSW e4677069d7 fix(syb): allow metadata-only partial collection links #259 2026-09-10 16:07:58 +08:00
QiuSW 8cfd32babe docs: record one-click replacement contract and boundaries #258 2026-09-10 15:52:03 +08:00
QiuSW 3181302c16 test: capture stable replacement confirmation states #258 2026-09-10 15:48:17 +08:00
QiuSW 2b01974fe5 feat(syb): confirm one-click replacement from latest collection #258 2026-09-10 15:47:27 +08:00
QiuSW 305062dba0 docs: record normalized color matching rule #257 2026-09-10 14:56:01 +08:00
QiuSW ea38ced58c fix(shopee): normalize colors before AI matching #257 2026-09-10 14:54:56 +08:00
QiuSW 2cb78ad8cd fix(collection): preserve task state during current-page reidentify #255 2026-09-10 14:26:39 +08:00
QiuSW ca997f6108 docs: record SYB quick-link behavior and API #253 2026-09-10 11:39:34 +08:00
QiuSW b1594dc7bd feat(syb): link latest device collection and match specs #253 2026-09-10 11:34:22 +08:00
QiuSW 08d939a9b8 docs: sync dynamic match timeout contract #254 2026-09-10 09:43:53 +08:00
QiuSW 9088e6b734 fix(shopee): bound matching waits and recover drawer loading #254 2026-09-10 09:37:02 +08:00
QiuSW fdd26afaf0 docs: record online deployment for #237 and #239 2026-09-08 10:26:01 +08:00
QiuSW d403f3b3a0 docs(syb): define page commits and partial success contract (#239) 2026-09-08 09:39:59 +08:00
QiuSW c6a962d522 fix(syb): persist validated pages and report partial sync success (#239) 2026-09-08 09:35:36 +08:00
112 changed files with 5013 additions and 2349 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 90
versionName = "0.9.77"
versionCode = 72
versionName = "0.9.59"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+2
View File
@@ -7,6 +7,8 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<queries>
<package android:name="com.xunmeng.pinduoduo" />
@@ -38,6 +38,7 @@ class MainActivity : AppCompatActivity() {
)
setContentView(buildContent())
requestNotificationPermission()
requestMediaPermission()
if (AgentSettingsStore(this).serverUrl().isNotBlank()) AgentForegroundService.start(this)
AgentAppUpdateManager.checkOnceAtStartup(this)
}
@@ -127,6 +128,14 @@ class MainActivity : AppCompatActivity() {
}
}
private fun requestMediaPermission() {
val permission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES
else Manifest.permission.READ_EXTERNAL_STORAGE
if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(permission), MEDIA_PERMISSION_REQUEST)
}
}
private fun applyScreenPolicy() {
val keepScreenOn = cn.ilapage.goauto.agent.service.AgentStateStore(this).read().keepScreenOn
if (keepScreenOn) {
@@ -145,5 +154,6 @@ class MainActivity : AppCompatActivity() {
private const val TAB_PREFERENCES = "goauto_agent_ui"
private const val LAST_SELECTED_TAB = "last_selected_tab"
private const val STATE_SELECTED_TAB = "selected_tab"
private const val MEDIA_PERMISSION_REQUEST = 101
}
}
@@ -434,16 +434,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
} else FreshActionResult.FAILED
}
private var lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN
override fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = lastPurchaseSwipeFailure
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN
val root = rootInActiveWindow ?: run {
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.ROOT_UNAVAILABLE
return false
}
val root = rootInActiveWindow ?: return false
val candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node }
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
@@ -452,12 +444,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
}
val target = (directional.ifEmpty { candidates }).maxByOrNull { candidate ->
Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height() }
} ?: run {
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.NO_SCROLLABLE
return false
}
return swipeNode(target, direction, durationMs, preferScrollAction = false,
onFailure = { lastPurchaseSwipeFailure = it })
} ?: return false
return swipeNode(target, direction, durationMs, preferScrollAction = false)
}
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
@@ -658,21 +646,16 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
preferScrollAction: Boolean = true,
downStartPercent: Int = 25,
downEndPercent: Int = 75,
onFailure: (PurchaseSwipeFailureReason) -> Unit = {},
): Boolean {
fun failed(reason: PurchaseSwipeFailureReason): Boolean {
onFailure(reason)
return false
}
val bounds = Rect().also(node::getBoundsInScreen)
if (bounds.width() < 2 || bounds.height() < 2) return failed(PurchaseSwipeFailureReason.INVALID_BOUNDS)
if (bounds.width() < 2 || bounds.height() < 2) return false
val scrollAction = if (direction == SwipeDirection.UP || direction == SwipeDirection.LEFT) {
AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
} else {
AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
}
if (preferScrollAction && node.performAction(scrollAction)) return true
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return failed(PurchaseSwipeFailureReason.GESTURE_UNSUPPORTED)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
val left = bounds.left + bounds.width() * 25 / 100
val right = bounds.left + bounds.width() * 75 / 100
val top = bounds.top + bounds.height() * 25 / 100
@@ -710,9 +693,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
},
null,
)
if (!queued) return failed(PurchaseSwipeFailureReason.GESTURE_REJECTED)
if (!latch.await(1500, TimeUnit.MILLISECONDS)) return failed(PurchaseSwipeFailureReason.GESTURE_TIMEOUT)
return completed.get() || failed(PurchaseSwipeFailureReason.GESTURE_CANCELLED)
if (!queued) return false
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
}
private fun dispatchCenterTap(bounds: Rect): Boolean {
@@ -1,58 +0,0 @@
package cn.ilapage.goauto.agent.automation
/** Complete temporary collections only; caller retains the existing task lock. */
internal class PddCollectionReturnNavigator(
private val driver: PddCollectorDriver,
private val openAgent: () -> Boolean,
private val currentPackage: () -> String?,
private val pause: (Long) -> Unit = Thread::sleep,
) {
var reason = "not_started"
private set
var actions = 0
private set
fun returnToAgent(count: Int): Boolean {
actions = 0
if (count !in 1..5) { reason = "invalid_count"; return false }
try {
reason = "count_completed"
for (step in 1..count) {
if (currentPackage() != PDD) { reason = "left_pdd"; break }
val screen = driver.capture()
if (screen.packageName != PDD) { reason = "left_pdd"; break }
if (screen.activityName.isNullOrBlank() || unsafe(screen)) {
reason = "unsafe_page"
return false
}
// Recheck after capturing: never send Back to another foreground app.
if (currentPackage() != PDD) { reason = "left_pdd"; break }
actions++
if (!driver.back()) { reason = "back_failed"; break }
pause(500)
}
// Retain risk scenes even if the last Back lands on one.
if (currentPackage() == PDD && unsafe(driver.capture())) {
reason = "unsafe_page"
return false
}
if (!openAgent()) { reason = "agent_launch_failed"; return false }
pause(500)
if (currentPackage() != AGENT) { reason = "agent_not_confirmed"; return false }
return true
} catch (_: Exception) {
reason = "navigation_exception"
return false
}
}
private fun unsafe(screen: UiSnapshot): Boolean =
PddPageClassifier.classify(screen.packageName, screen.activityName,
screen.nodes.filter { it.visible }.map { it.label }) != null ||
screen.nodes.any { it.visible && listOf("订单详情", "订单待支付", "确认付款", "立即支付", "编辑收货地址", "新增收货地址", "地址管理").any(it.label::contains) }
companion object {
private const val PDD = "com.xunmeng.pinduoduo"
private const val AGENT = "cn.ilapage.goauto.agent"
}
}
@@ -186,7 +186,7 @@ object PddScreenParser {
// purchase/order/payment controls must never become collection click targets.
private val nonConfigurableClickDenylist = listOf("提交订单", "确认订单", "支付", "付款")
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null, collectionContext: Boolean = false, purchaseEntryContext: Boolean = false): ParsedPddScreen {
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null): ParsedPddScreen {
val visibleNodes = snapshot.nodes.filter { it.visible }
val visible = visibleNodes.mapNotNull { node ->
val descendants = descendants(node, visibleNodes)
@@ -323,32 +323,12 @@ object PddScreenParser {
(headings.size >= 2 || (continuedPurchasePanel && headings.isNotEmpty())) &&
dimensions.any { it.values.isNotEmpty() } && hasClose &&
hasQuantityControls && hasPaymentArea && hasOrderSubmitAction
// Collection can start before any selection summary exists and with only
// one dimension visible. Keep this exception out of purchase parsing.
val singleDimensionCollectionPanel = collectionContext && purchaseContext == null &&
snapshot.packageName == PDD_PACKAGE && problem == null && !hasSelectionSummary &&
evidence != null && snapshot.packageName == evidence.packageName &&
snapshot.activityName == evidence.activityName &&
snapshot.nodes.any { it.visible && it.matches(evidence.selector) } &&
boundedScrollables.size == 1 && headedPanelScrollable != null && headings.size == 1 &&
dimensions.size == 1 && dimensions.single().values.isNotEmpty() &&
hasClose && hasQuantityControls && hasPaymentArea && hasOrderSubmitAction
// Only the executor that just activated a safe entry on this same
// product Activity can enable this first-view purchase exception.
val singleDimensionPurchasePanel = purchaseEntryContext && !collectionContext &&
snapshot.packageName == PDD_PACKAGE && !snapshot.activityName.isNullOrBlank() &&
goodsId.isNotBlank() && problem == null && !hasSelectionSummary &&
boundedScrollables.size == 1 && headedPanelScrollable != null && headings.size == 1 &&
dimensions.size == 1 && dimensions.single().values.isNotEmpty() &&
hasClose && hasQuantityControls && hasPaymentArea && hasOrderSubmitAction
// Some selected-spec panels omit both the "已选" prefix and a confirm
// button. Keep the same structural evidence required for checkout.
val specPanelType = when {
quickConfirmationEvidence -> SpecPanelType.QUICK_CONFIRMATION
orderConfirmationEvidence -> SpecPanelType.ORDER_CONFIRMATION
structuredSelectionPanel -> SpecPanelType.NORMAL_SCROLLABLE
singleDimensionCollectionPanel -> SpecPanelType.NORMAL_SCROLLABLE
singleDimensionPurchasePanel -> SpecPanelType.NORMAL_SCROLLABLE
panelScrollable != null && (hasSelectionSummary || hasSubmitHint || (hasPanelTitle && hasPanelAction)) -> SpecPanelType.NORMAL_SCROLLABLE
hasSelectionSummary && hasPanelTitle && hasPanelAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
// Some PDD builds expose the complete selector as non-scrollable
@@ -1785,7 +1765,7 @@ class PddProductDetailCollector(
}
private fun parse(goodsId: String, config: PddCollectorConfig, evidence: PageEvidence) =
PddScreenParser.parse(driver.capture(), config, goodsId, evidence, collectionContext = true)
PddScreenParser.parse(driver.capture(), config, goodsId, evidence)
private fun screenSignature(screen: ParsedPddScreen): List<String> = screen.sourceNodes.asSequence()
.filter(SnapshotNode::visible)
@@ -0,0 +1,44 @@
package cn.ilapage.goauto.agent.automation
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import cn.ilapage.goauto.agent.network.ImageSearchImage
import java.security.MessageDigest
import java.util.UUID
data class PreparedImageSearchAsset(val uri: Uri)
object ImageSearchAssetPolicy {
private const val MAX_BYTES = 10 * 1024 * 1024
fun valid(bytes: ByteArray, reference: ImageSearchImage): Boolean {
if (bytes.isEmpty() || bytes.size.toLong() != reference.sizeBytes || bytes.size > MAX_BYTES) return false
if (reference.mediaType != "image/jpeg" && reference.mediaType != "image/png") return false
return sha256(bytes) == reference.sha256.lowercase()
}
fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
}
class PinduoduoImageSearchAssetStore(context: Context) {
private val resolver = context.applicationContext.contentResolver
fun prepare(bytes: ByteArray, reference: ImageSearchImage): PreparedImageSearchAsset? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || !ImageSearchAssetPolicy.valid(bytes, reference)) return null
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "goauto-search-${UUID.randomUUID()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/GoAutoSearch")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return null
return try {
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: error("image output unavailable")
resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null)
PreparedImageSearchAsset(uri)
} catch (_: Exception) { resolver.delete(uri, null, null); null }
}
fun delete(asset: PreparedImageSearchAsset?) { asset?.let { resolver.delete(it.uri, null, null) } }
}
@@ -32,12 +32,8 @@ class PurchaseLiveException(val code: String, message: String, val actualUnitPri
class PurchaseLiveAutomation(
private val driver: PurchaseUiDriver,
private val pause: (Long) -> Unit = Thread::sleep,
private val diagnostic: (String) -> Unit = {},
) {
private var submitAttempted = false
private var savedAddressProof: ShippingAddressProof? = null
private var savedAddressActivity: String? = null
private var structuralSubmitAnchor: SnapshotNode? = null
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
private set
@@ -127,9 +123,7 @@ class PurchaseLiveAutomation(
}
pause(1_000)
snapshot = waitFor("PURCHASE_ADDRESS_PANEL_TIMEOUT", "收货地址页面打开超时,未创建订单") {
it.packageName == PDD_PACKAGE &&
it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" } &&
addressModifyTargets(it).size == 1
it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" }
}
return editAndVerifyAddress(snapshot, addressSuffix)
}
@@ -307,11 +301,10 @@ class PurchaseLiveAutomation(
private fun nodeArea(node: SnapshotNode): Long = node.bounds.width.toLong() * node.bounds.height
fun finalConfirmation(input: PurchaseExecutionInput, address: ShippingAddressProof): FinalConfirmationEvidence {
structuralSubmitAnchor = null
val snapshot = driver.capture()
pageProblem(snapshot)
if (snapshot.packageName != PDD_PACKAGE) fail("PURCHASE_CONFIRMATION_LOST", "最终提交前页面已经变化,禁止创建订单")
if (!hasSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
if (!hasFinalSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址保存后复核失败,未创建订单")
}
val submit = finalSubmitTargets(snapshot)
@@ -325,7 +318,6 @@ class PurchaseLiveAutomation(
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
val quantities = snapshot.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true }.mapNotNull { it.label.toLongOrNull() }
if (quantities.singleOrNull() != input.quantity) fail("PURCHASE_QUANTITY_MISMATCH", "创建订单前数量复核失败")
structuralSubmitAnchor = submit.single().takeIf { legacySubmitTargets(snapshot).isEmpty() }
return FinalConfirmationEvidence(input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty())
}
@@ -337,18 +329,6 @@ class PurchaseLiveAutomation(
if (snapshot.packageName != PDD_PACKAGE || targets.size != 1) {
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "最终页面或创建订单按钮已变化,禁止创建订单")
}
if (legacySubmitTargets(snapshot).isEmpty() && structuralSubmitAnchor == null) {
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "下单按钮尚未完成最终复核,禁止创建订单")
}
structuralSubmitAnchor?.let { anchor ->
val fresh = targets.single()
if (fresh.path != anchor.path || fresh.className != anchor.className || fresh.label != anchor.label ||
kotlin.math.abs(fresh.bounds.left - anchor.bounds.left) > 32 ||
kotlin.math.abs(fresh.bounds.top - anchor.bounds.top) > 32 ||
kotlin.math.abs(fresh.bounds.right - anchor.bounds.right) > 32 ||
kotlin.math.abs(fresh.bounds.bottom - anchor.bounds.bottom) > 32
) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "最终下单按钮已变化,禁止创建订单")
}
submitAttempted = true
when (driver.clickFresh(targets.single())) {
FreshActionResult.SUCCESS -> Unit
@@ -486,13 +466,12 @@ class PurchaseLiveAutomation(
snapshot.packageName == PDD_PACKAGE && snapshot.activityName in PDD_PAYMENT_ACTIVITIES
private fun editAndVerifyAddress(panel: UiSnapshot, suffix: String): ShippingAddressProof {
savedAddressProof = null
savedAddressActivity = null
structuralSubmitAnchor = null
val modify = addressModifyTargets(panel)
if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单")
click(modify.single(), "修改地址", 1_000)
val edit = waitForAddressEditorReady()
click(modify.single(), "修改地址")
val edit = waitFor("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单") { snapshot ->
snapshot.nodes.any { it.visible && it.label.replace(" ", "").contains("详细地址") }
}
val editors = shippingAddressEditors(edit)
if (editors.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的详细地址输入框,未创建订单")
val current = editors.single().label.trim()
@@ -505,30 +484,19 @@ class PurchaseLiveAutomation(
}
val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" })
if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单")
click(save.single(), "保存地址", 1_000)
var savedEvidence = waitForSavedConfirmation(waitForStableAddressEditorExit(), expected, suffix)
if (!savedAddressPageReady(savedEvidence, expected, suffix)) {
click(save.single(), "保存地址")
val savedEvidence = waitForStableAddressEditorExit()
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
if (isPurchaseConfirmationPanel(savedEvidence)) {
savedEvidence = restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
} else {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
pause(1_000)
val returned = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
savedAddressPageReady(it, expected, suffix) || isPurchaseConfirmationPanel(it)
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix)
}
val settled = waitForSavedConfirmation(returned, expected, suffix)
savedEvidence = if (!savedAddressPageReady(settled, expected, suffix)) {
restoreFinalEvidenceInCurrentPanel(settled, expected, suffix)
} else settled
}
}
val proof = ShippingAddressProof(expected, suffix)
savedAddressProof = proof
savedAddressActivity = savedEvidence.activityName
if (finalSubmitTargets(savedEvidence).size != 1) {
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "地址已保存,但未能识别唯一的下单按钮,未创建订单")
}
return proof
return ShippingAddressProof(expected, suffix)
}
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
@@ -541,56 +509,21 @@ class PurchaseLiveAutomation(
)
}
/** Wait for late address/footer nodes before deciding to scroll. No device actions here. */
private fun waitForSavedConfirmation(initial: UiSnapshot, expected: String, suffix: String): UiSnapshot {
var snapshot = initial
var readyActivity: String? = null
repeat(50) { poll ->
pageProblem(snapshot)
// A stable address list needs the existing single Back, not a footer wait.
if (snapshot.nodes.any { it.visible && it.label == "收货地址" } &&
addressModifyTargets(snapshot).size == 1) return snapshot
val ready = savedAddressPageReady(snapshot, expected, suffix)
if (ready && readyActivity != null && readyActivity == snapshot.activityName) {
savedConfirmationDiagnostic(snapshot, expected, suffix, poll + 1)
return snapshot
}
readyActivity = if (ready) snapshot.activityName else null
if (poll < 49) {
pause(200)
snapshot = driver.capture()
}
}
savedConfirmationDiagnostic(snapshot, expected, suffix, 50)
return snapshot
}
private fun savedConfirmationDiagnostic(snapshot: UiSnapshot, expected: String, suffix: String, polls: Int) {
val address = resolveAddressEntries(snapshot)
diagnostic("savedAddressMatched=${hasSavedAddressEvidence(snapshot, expected, suffix)};" +
"savedPanelMatched=${savedPurchasePanel(snapshot, expected, suffix) != null};" +
"savedLegacySubmitCandidates=${legacySubmitTargets(snapshot).size};" +
"savedAddressCards=${address.addressCardCount};savedAddressTargets=${address.tapTargetCount};" +
"savedScrollCandidates=${savedAddressPanelScrollTargets(snapshot).size};savedConfirmationPolls=$polls")
}
private fun restoreFinalEvidenceInCurrentPanel(
initial: UiSnapshot,
expected: String,
suffix: String,
): UiSnapshot {
) {
var snapshot = initial
var previousSignature: String? = null
repeat(ADDRESS_CONFIRMATION_SCROLL_LIMIT) { attempt ->
if (savedAddressPageReady(snapshot, expected, suffix)) return snapshot
if (hasFinalSavedAddressEvidence(snapshot, expected, suffix)) return
if (!isPurchaseConfirmationPanel(snapshot)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后采购面板发生变化,未创建订单")
}
val panels = savedAddressPanelScrollTargets(snapshot)
val panels = purchasePanelScrollTargets(snapshot)
if (panels.size != 1) {
savedConfirmationDiagnostic(snapshot, expected, suffix, 0)
val reason = if (panels.isEmpty()) "未找到可用的规格面板滚动区域" else "找到多个规格面板滚动区域"
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后${reason},未创建订单")
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后没有找到唯一的规格面板滚动区域,未创建订单")
}
val panel = panels.single()
val signature = viewportSignature(snapshot, panel)
@@ -605,10 +538,9 @@ class PurchaseLiveAutomation(
snapshot = driver.capture()
pageProblem(snapshot)
}
if (!savedAddressPageReady(snapshot, expected, suffix)) {
if (!hasFinalSavedAddressEvidence(snapshot, expected, suffix)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
}
return snapshot
}
private fun hasSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean {
@@ -625,9 +557,8 @@ class PurchaseLiveAutomation(
return suffixMatches.size == 1
}
private fun savedAddressPageReady(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
hasSavedAddressEvidence(snapshot, expected, suffix) &&
(legacySubmitTargets(snapshot).isNotEmpty() || savedPurchasePanel(snapshot, expected, suffix) != null)
private fun hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).size == 1
private fun waitForStableAddressEditorExit(): UiSnapshot {
var consecutiveExits = 0
@@ -660,20 +591,6 @@ class PurchaseLiveAutomation(
fail(code, message)
}
private fun savedAddressPanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
val panel = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
.specPanelContainer ?: return purchasePanelScrollTargets(snapshot)
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
return snapshot.nodes.filter { node ->
node.visible && node.enabled && node.scrollable &&
node.bounds.width * 100 >= screenWidth * 60 &&
node.bounds.height * 100 >= screenHeight * 20 &&
node.bounds.left >= panel.bounds.left && node.bounds.right <= panel.bounds.right &&
node.bounds.top >= panel.bounds.top && node.bounds.bottom <= panel.bounds.bottom
}.distinctBy { it.path }
}
private fun purchasePanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
@@ -752,113 +669,20 @@ class PurchaseLiveAutomation(
}.distinctBy { it.path }
}
private fun waitForAddressEditorReady(): UiSnapshot {
var previous: SnapshotNode? = null
var previousActivity: String? = null
var count = 0
repeat(50) { poll ->
val snapshot = driver.capture()
pageProblem(snapshot)
val editors = if (snapshot.packageName == PDD_PACKAGE) shippingAddressEditors(snapshot) else emptyList()
count = editors.size
diagnostic("addressEditorCandidates=$count;addressEditorPolls=${poll + 1}")
val editor = editors.singleOrNull()
if (editor != null && previous != null && snapshot.activityName == previousActivity &&
editor.path == previous!!.path && editor.bounds == previous!!.bounds && editor.label == previous!!.label) return snapshot
previous = editor
previousActivity = snapshot.activityName
pause(200)
}
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址输入框未稳定就绪,未创建订单 [candidates=$count]")
}
private fun click(node: SnapshotNode, label: String, settleMillis: Long = 500) {
private fun click(node: SnapshotNode, label: String) {
when (driver.clickFresh(node)) {
FreshActionResult.SUCCESS -> Unit
FreshActionResult.AMBIGUOUS -> fail("RULE_AMBIGUOUS", "$label 不唯一")
else -> fail("RULE_ACTION_FAILED", "$label 点击失败")
}
pause(settleMillis)
pause(500)
}
private fun legacySubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
snapshot,
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
)
/** Only the just-saved address flow may use a footer whose display copy is unknown. */
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> {
val legacy = legacySubmitTargets(snapshot)
if (legacy.isNotEmpty()) return legacy
val proof = savedAddressProof ?: return emptyList()
if (snapshot.activityName != savedAddressActivity) return emptyList()
val panel = savedPurchasePanel(snapshot, proof.expectedAddress, proof.suffix) ?: return emptyList()
val visible = snapshot.nodes.filter { it.visible }
val byPath = visible.associateBy(SnapshotNode::path)
val candidates = visible.filter { node ->
node.path.startsWith("${panel.path}/") && node.enabled && node.clickable && !node.scrollable &&
node.bounds.width >= panel.bounds.width / 5 && node.bounds.height >= 2 &&
node.bounds.height <= panel.bounds.height / 4 &&
node.bounds.bottom <= panel.bounds.bottom &&
node.bounds.bottom >= panel.bounds.bottom - panel.bounds.height / 20 &&
node.bounds.left >= panel.bounds.left && node.bounds.right <= panel.bounds.right
}.filter { node ->
// A spec option at the scroll edge is not a fixed footer action.
var parent = node.parentPath?.let(byPath::get)
var inScroller = false
while (parent != null && parent.path != panel.path) {
if (parent.scrollable) inScroller = true
parent = parent.parentPath?.let(byPath::get)
}
!inScroller && parent?.path == panel.path
}
val independent = candidates.filter { parent ->
candidates.none { it.path.startsWith("${parent.path}/") }
}
// Do not silently choose one of several footer actions by text or price.
if (independent.size != 1) return emptyList()
val button = independent.single()
val content = visible.filter { it.path == button.path || it.path.startsWith("${button.path}/") }
val labels = content.map { it.label.replace(" ", "") }
if (labels.any { label -> STRUCTURAL_ACTION_DENY.any(label::contains) } ||
labels.none { FOOTER_PRICE.containsMatchIn(it) } ||
labels.none { FOOTER_PRICE.replace(it, "").any(Char::isLetter) }
) return emptyList()
// Keep the original text anchor; clickFresh reacquires it and its nearest clickable parent.
return listOfNotNull(content.firstOrNull {
it.enabled && it.label.isNotBlank() && nearestClickableAncestor(it, byPath)?.path == button.path
})
}
private fun savedPurchasePanel(snapshot: UiSnapshot, expected: String, suffix: String): SnapshotNode? {
if (snapshot.packageName != PDD_PACKAGE || snapshot.activityName.isNullOrBlank() ||
isKnownPddPaymentActivity(snapshot) || !hasSavedAddressEvidence(snapshot, expected, suffix)
) return null
val visible = snapshot.nodes.filter { it.visible }
if (visible.any { node -> STRUCTURAL_PAGE_DENY.any(node.label::contains) }) return null
val resolution = resolveAddressEntries(snapshot)
if (resolution.addressCardCount != 1 || resolution.tapTargetCount != 1) return null
val address = resolution.target?.node ?: return null
val quantities = visible.filter { it.enabled && it.className?.endsWith("EditText") == true && (it.label.toLongOrNull() ?: 0) > 0 }
val summaries = visible.filter { it.label.replace(" ", "").startsWith("已选") }
val evidence = quantities.singleOrNull() ?: summaries.singleOrNull() ?: return null
val byPath = visible.associateBy(SnapshotNode::path)
val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null
var common = address.parentPath?.let(byPath::get)
while (common != null) {
if (evidence.path.startsWith("${common.path}/")) {
// Nearest common ancestor is the whole popup, not its inner spec RecyclerView.
return common.takeIf {
!it.scrollable && it.bounds.width >= screenWidth * 3 / 5 &&
it.bounds.height > 0 && it.bounds.height < screenHeight * 95 / 100
}
}
common = common.parentPath?.let(byPath::get)
}
return null
}
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
@@ -922,9 +746,6 @@ class PurchaseLiveAutomation(
const val WECHAT_PACKAGE = "com.tencent.mm"
val MASKED_PHONE = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
val FOOTER_PRICE = Regex("(?:[¥¥]\\s*\\d+(?:\\.\\d{1,2})?|\\d+(?:\\.\\d{1,2})?\\s*元)")
val STRUCTURAL_ACTION_DENY = listOf("支付", "付款", "先用后付", "收货", "地址", "修改", "保存", "取消", "返回", "关闭", "客服", "收藏", "订单详情", "查看订单")
val STRUCTURAL_PAGE_DENY = listOf("立即支付", "确认支付", "输入支付密码", "待付款", "待支付", "订单编号", "订单号", "订单详情", "修改收货地址", "选择收货地址")
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情")
@@ -3,11 +3,6 @@ package cn.ilapage.goauto.agent.automation
import java.net.URI
import java.net.URLDecoder
enum class PurchaseSwipeFailureReason {
UNKNOWN, ROOT_UNAVAILABLE, NO_SCROLLABLE, INVALID_BOUNDS, GESTURE_UNSUPPORTED,
GESTURE_REJECTED, GESTURE_CANCELLED, GESTURE_TIMEOUT,
}
interface PurchaseUiDriver {
fun capture(): UiSnapshot
fun clickFresh(target: SnapshotNode): FreshActionResult
@@ -37,7 +32,6 @@ interface PurchaseUiDriver {
fun specRowSwipeFailureReason(): String = "gestureFailed"
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = PurchaseSwipeFailureReason.UNKNOWN
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550)
fun backPurchase(): Boolean
@@ -69,7 +63,6 @@ data class PurchaseExecutionInput(
val mappedColor: String,
val mappedSize: String,
val specResolutionAllowed: Boolean = false,
val specRematchAllowed: Boolean = false,
val quantity: Long,
val minUnitPriceCent: Long,
val maxUnitPriceCent: Long,
@@ -94,24 +87,17 @@ class PurchaseRehearsalExecutor(
private val stepChanged: (String) -> Unit = {},
private val panelDiagnostic: (String) -> Unit = {},
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
private val probeHandoffActivity: String? = null,
private val onProbeHandoff: (String) -> Unit = {},
private val rematchSpecs: ((PurchaseExecutionInput) -> PurchaseExecutionInput?)? = null,
) {
private var purchasePanelContext: PurchasePanelContext? = null
private var pageIdentity: Pair<String?, String?> = null to null
private var purchaseEntryIdentity: Pair<String?, String?>? = null
private var entryRecoveryReason = "not_checked"
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
purchasePanelContext = null
purchaseEntryIdentity = null
validateBeforeDeviceAction(input, rule, supportedCapabilities)?.let { return it }
var observedPrice: Long? = null
var addressProof: ShippingAddressProof? = null
var irreversibleStarted = false
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic)
val live = PurchaseLiveAutomation(driver, pause)
for (action in rule.actions) {
// The immediate phase-two handoff can reuse the PDD page retained by
// spec_probe. A later manual retry may start from Agent (or another
@@ -129,14 +115,14 @@ class PurchaseRehearsalExecutor(
PurchaseActionType.OPEN_SPEC_PANEL -> {
openSpecPanel(input, action) ?: recoverSoldOut(input, closeSpecPanel = true) ?: openSpecPanel(input, action)
}
PurchaseActionType.SELECT_SPEC -> if (input.phase == "spec_probe") null else selectSpecsWithRematch(input, rule, specSelectionProofs)
PurchaseActionType.SELECT_SPEC -> if (input.phase == "spec_probe") null else selectSpecs(input, rule, specSelectionProofs)
PurchaseActionType.SET_QUANTITY -> if (input.phase == "spec_probe") null else setQuantity(input.quantity)
PurchaseActionType.VERIFY_UNIT_PRICE -> if (input.phase == "spec_probe") null else verifyPrice(input).also {
if (it == null) observedPrice = currentScreen(input).priceCent
}
PurchaseActionType.VERIFY_ORDER_SUMMARY -> if (input.phase == "spec_probe") null else verifySummary(input, observedPrice, specSelectionProofs)
PurchaseActionType.PROBE_SPECS -> {
if (input.phase == "spec_probe") return probeOutcome(input)
if (input.phase == "spec_probe") return probeOutcome()
null
}
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try {
@@ -181,7 +167,7 @@ class PurchaseRehearsalExecutor(
input.specResolutionAllowed &&
PurchaseActionType.PROBE_SPECS in rule.actions.map { it.type }
) {
return probeOutcome(input)
return probeOutcome()
}
return failure
}
@@ -192,42 +178,10 @@ class PurchaseRehearsalExecutor(
else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作")
}
private fun selectSpecsWithRematch(input: PurchaseExecutionInput, rule: PurchaseRule, proofs: MutableMap<String, ExactSpecSelectionProof>): PurchaseExecutionOutcome? {
val first = selectSpecs(input, rule, proofs)
if (first?.errorCode != SPEC_TARGET_NOT_VISIBLE || !input.specRematchAllowed || rematchSpecs == null) return first
val refreshed = rematchSpecs.invoke(input) ?: return first
panelDiagnostic("specRematch=accepted;pageReuse=true")
proofs.clear()
return selectSpecs(refreshed.copy(specRematchAllowed = false), rule, proofs)
}
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean {
if (probeHandoffActivity == null) {
val screen = currentScreen(input)
val reuse = screen.problem == null && screen.hasPurchaseProductEvidence()
panelDiagnostic("handoffPresent=false;productReused=$reuse;reusePolls=1;reuseReason=legacy_evidence")
return reuse
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean =
currentScreen(input).let { screen ->
screen.problem == null && screen.hasPurchaseProductEvidence()
}
var consecutive = 0
repeat(10) { poll ->
purchaseEntryIdentity = PDD_PACKAGE to probeHandoffActivity
val screen = currentScreen(input)
if ((pageIdentity.first != null && pageIdentity != (PDD_PACKAGE to probeHandoffActivity)) || screen.problem != null) {
purchaseEntryIdentity = null
panelDiagnostic("handoffPresent=true;productReused=false;reusePolls=${poll + 1};reuseReason=${if (screen.problem != null) "page_problem" else "page_changed"}")
return false
}
consecutive = if (screen.hasPurchaseProductEvidence()) consecutive + 1 else 0
if (consecutive >= 2) {
panelDiagnostic("handoffPresent=true;productReused=true;reusePolls=${poll + 1};reuseReason=handoff_ready")
return true
}
if (poll < 9) pause(200)
}
purchaseEntryIdentity = null
panelDiagnostic("handoffPresent=true;productReused=false;reusePolls=10;reuseReason=evidence_timeout")
return false
}
private fun validateBeforeDeviceAction(
input: PurchaseExecutionInput,
@@ -416,10 +370,7 @@ class PurchaseRehearsalExecutor(
screen = currentScreen(input)
}
val beforeSignature = specActionSignature(screen)
val beforeIdentity = pageIdentity
val click = driver.clickFreshDetailed(requireNotNull(target))
purchaseEntryIdentity = if (click.result == FreshActionResult.SUCCESS) beforeIdentity else null
panelDiagnostic("entrySource=${screen.specEntrySource ?: "unknown"};entryClick=${click.result};entryReason=${click.reason}")
when (click.result) {
// The parser already narrowed to a single semantic candidate; the
// ambiguity here comes from the live tree matching that target more
@@ -434,22 +385,14 @@ class PurchaseRehearsalExecutor(
var wait = waitForSpecPanel(input, beforeSignature)
wait.failure?.let { return it }
if (wait.opened) return null
val refreshed = currentScreen(input)
refreshed.problem?.let { return failure(it.code, it.message) }
if (refreshed.reviewPageOpen) return leaveUnexpectedReviewPage(input)
if (refreshed.specPanelOpen) return null
val recoveryTarget = recoverableSpecEntry(screen, refreshed, action, beforeIdentity)
if (recoveryTarget == null) {
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(refreshed)}")
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
}
val gesture = driver.tapSpecFresh(requireNotNull(recoveryTarget))
panelDiagnostic("entryRecovery=attempted;gesture=$gesture;pageChanged=${wait.changed}")
when (gesture) {
when (driver.tapSpecFresh(requireNotNull(target))) {
FreshActionResult.AMBIGUOUS -> return failure(
SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
@@ -463,8 +406,7 @@ class PurchaseRehearsalExecutor(
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed && recoverableSpecEntry(screen, wait.screen, action, beforeIdentity) == null) {
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(wait.screen)}")
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
@@ -476,55 +418,6 @@ class PurchaseRehearsalExecutor(
)
}
// Whole-page animations are not proof of navigation. Recover only a freshly
// parsed, unchanged semantic product entry; never reuse the old node/coordinates.
private fun recoverableSpecEntry(
before: ParsedPddScreen,
after: ParsedPddScreen,
action: PurchaseAction,
identity: Pair<String?, String?>,
): SnapshotNode? {
entryRecoveryReason = when {
identity.first == null || identity.second == null -> "identity_missing"
identity != pageIdentity -> "identity_changed"
!after.isPddPackage || !after.pageEvidenceMatched -> "product_context_missing"
after.problem != null || after.reviewPageOpen -> "page_problem"
after.specPanelOpen -> "panel_open"
after.hasCloseControl -> "close_control"
// A product-page payment-method promotion is not a checkout page.
// Keep real order/payment/address evidence blocking the one entry retry.
pageIdentity.second?.contains("pay", ignoreCase = true) == true ||
after.sourceNodes.any { node -> node.visible && listOf(
"立即支付", "确认支付", "去支付", "去付款", "支付密码", "收银台",
"订单详情", "订单编号", "订单号", "待付款", "待支付", "确认订单",
"提交订单", "收货地址", "收货信息",
).any(node.label::contains) } -> "payment_area"
after.hasOrderSubmitAction -> "order_action"
after.hasQuantityControls -> "quantity_controls"
after.hasSelectionSummary -> "selection_summary"
before.summary.title.isNullOrBlank() -> "title_missing"
before.summary.title != after.summary.title -> "title_changed"
after.specEntry == null -> "entry_missing"
before.specEntrySource == null -> "entry_source_missing"
before.specEntrySource != after.specEntrySource -> "entry_source_changed"
before.specEntry?.label != after.specEntry.label -> "entry_label_changed"
after.explicitSpecEntryCount > 1 || after.nestedSpecEntryCount > 1 -> "entry_ambiguous"
else -> "eligible"
}
if (entryRecoveryReason != "eligible") return null
val anchor = after.specEntry ?: return null
if (action.textAliases?.let { specEntryMatchesAliases(after, anchor, it) } == false) {
entryRecoveryReason = "rule_alias_mismatch"
return null
}
val target = after.specEntryClickTarget ?: anchor
if (!target.visible || !target.enabled) {
entryRecoveryReason = "entry_unavailable"
return null
}
return target
}
private data class SpecPanelWait(
val screen: ParsedPddScreen,
val opened: Boolean,
@@ -547,9 +440,9 @@ class PurchaseRehearsalExecutor(
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
var last = currentScreen(input)
var changed = false
repeat(SPEC_POST_CLICK_VERIFY_POLLS) { poll ->
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
last = currentScreen(input)
panelDiagnostic("${panelEvidence(last)};entryWaitPolls=${poll + 1};entryWaitMillis=${poll * SPEC_SELECTION_POLL_MILLIS};closeControl=${last.hasCloseControl};paymentArea=${last.hasPaymentArea}")
panelDiagnostic(panelEvidence(last))
if (last.reviewPageOpen) {
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
}
@@ -650,7 +543,6 @@ class PurchaseRehearsalExecutor(
): PurchaseExecutionOutcome? {
val initial = currentScreen(input)
initial.problem?.let { return failure(it.code, it.message) }
unavailableExactSpec(initial, dimension, target)?.let { return it }
if (rememberExactSelection(initial, dimension, target, selectionProofs)) return null
val lookup = locateExactSpec(input, dimension, target)
@@ -725,9 +617,7 @@ class PurchaseRehearsalExecutor(
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 dimension == "color" && SelectedColorCard.confirms(target, candidates, screen.sourceNodes)
}
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) return false
if (candidates.any { it.text == target && (it.node.selected || it.node.checked) }) return true
if (screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target)) return true
return summarySelectionMatches(screen.selectedSummary, dimension, target, candidates)
@@ -901,18 +791,6 @@ class PurchaseRehearsalExecutor(
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
private fun unavailableExactSpec(screen: ParsedPddScreen, dimension: String, target: String): PurchaseExecutionOutcome? {
if (!screen.specPanelOpen) return null
val exact = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { it.text == target }
return if (exact.size == 1 && !exact.single().available) unavailableSpec(dimension) else null
}
private fun unavailableSpec(dimension: String): PurchaseExecutionOutcome {
val role = if (dimension == "color") "颜色" else "尺码"
panelDiagnostic("specLookup=unavailable;dimension=$dimension;reason=exact_target_unavailable")
return failure(SPEC_SAFE_TARGET_MISSING, "目标${role}已售罄或当前不可选,未创建订单")
}
/**
* Searches only parsed, selectable values in the already-open spec panel.
* A short downward pass first restores the top when a previous action left
@@ -949,7 +827,7 @@ class PurchaseRehearsalExecutor(
}
if (exact.size == 1 && exact.single().available) return Inspection(SpecLookup(node = exact.single().node), "", screen.specPanelContainer)
if (exact.size == 1) {
return Inspection(SpecLookup(failure = unavailableSpec(dimension)), "", screen.specPanelContainer)
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}" }}"
@@ -1014,7 +892,7 @@ class PurchaseRehearsalExecutor(
val lookup = when {
exact.size > 1 -> SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件"))
exact.size == 1 && exact.single().available -> SpecLookup(node = exact.single().node)
exact.size == 1 -> SpecLookup(failure = unavailableSpec(dimension))
exact.size == 1 -> SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击"))
else -> null
}
val rows = specValueRows(values)
@@ -1216,17 +1094,16 @@ class PurchaseRehearsalExecutor(
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
action.swipeAfter?.let { swipe ->
// Legacy rules prescribe a blind scroll immediately after opening.
// Opening has already been verified; probe/select owns any necessary
// bounded scrolling. Do not let an unnecessary gesture block either
// phase, or move already-visible exact specs out of the viewport.
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL) {
panelDiagnostic("postSwipe=skipped;action=${action.type.wireName};reason=spec_panel_on_demand;panel=${currentScreen(input).specPanelType.name}")
return null
}
// The stock purchase rule asks to reveal additional selector rows after
// opening the sheet. A fully-evidenced non-scrollable selector has no
// scroll target, and treating that absence as an action failure blocks
// an otherwise safe exact-spec flow. Keep all other configured swipes
// mandatory; this exception is limited to that confirmed panel state.
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL &&
currentScreen(input).specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION
) return null
repeat(swipe.count) { index ->
if (!driver.swipePurchase(swipe.direction, swipe.durationMs)) {
panelDiagnostic("postSwipe=failed;action=${action.type.wireName};direction=${swipe.direction.name};swipeIndex=${index + 1};reason=${driver.purchaseSwipeFailureReason().name.lowercase()}")
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败")
}
if (index < swipe.count - 1 && swipe.intervalMs > 0) pause(swipe.intervalMs)
@@ -1235,13 +1112,9 @@ class PurchaseRehearsalExecutor(
return null
}
private fun probeOutcome(input: PurchaseExecutionInput): PurchaseExecutionOutcome {
private fun probeOutcome(): PurchaseExecutionOutcome {
stepChanged("probeSpecs")
val result = probeSpecs() ?: return failure("PURCHASE_SPEC_NOT_MATCHED", "商品规格探测失败")
val screen = currentScreen(input)
if (input.phase == "spec_probe" && screen.problem == null && screen.isPddPackage && screen.specPanelOpen) {
pageIdentity.second?.takeIf(String::isNotBlank)?.let(onProbeHandoff)
}
return PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = result)
}
@@ -1254,10 +1127,7 @@ class PurchaseRehearsalExecutor(
private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen {
val snapshot = driver.capture()
pageIdentity = snapshot.packageName to snapshot.activityName
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext,
purchaseEntryContext = purchaseEntryIdentity != null && purchaseEntryIdentity == pageIdentity)
if (purchaseEntryIdentity != pageIdentity) purchaseEntryIdentity = null
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext)
purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) {
screen.specPanelContainer?.let {
PurchasePanelContext(it, normalizedTarget("color", input.mappedColor).orEmpty())
@@ -1,43 +0,0 @@
package cn.ilapage.goauto.agent.automation
/** A narrow exception for one selected text option duplicated by its card/image. */
internal object SelectedColorCard {
fun confirms(target: String, values: List<VisibleSpecValue>, source: List<SnapshotNode>): Boolean {
val exact = values.filter { it.text == target }.singleOrNull() ?: return false
if (!exact.node.selected && !exact.node.checked) return false
val byPath = source.associateBy { it.path }
fun contains(outer: NodeBounds, inner: NodeBounds) =
inner.width > 0 && inner.height > 0 && inner.left >= outer.left && inner.top >= outer.top &&
inner.right <= outer.right && inner.bottom <= outer.bottom
fun cardFor(node: SnapshotNode): SnapshotNode? {
var current = node
val seen = mutableSetOf<String>()
while (seen.add(current.path)) {
if (current.scrollable || !current.visible || !current.enabled) return null
val parent = current.parentPath?.let(byPath::get) ?: return null
if (parent.scrollable) {
return current.takeIf {
it.clickable && it.className == "android.view.ViewGroup" &&
contains(parent.bounds, it.bounds) && contains(it.bounds, node.bounds)
}
}
current = parent
}
return null
}
val card = cardFor(exact.node) ?: return false
// The parser may have deduplicated identical labels across cards. Check
// the source as well before treating a selected duplicate as harmless.
if (source.any { node ->
node.visible && node.enabled && node.clickable &&
SpecValueNormalizer.normalizeColor(node.label) == target &&
cardFor(node)?.let { it.path != card.path } == true
}) return false
return values.filter { it.text != target && (it.node.selected || it.node.checked) }.all { other ->
val node = other.node
// Never merge another textual option, adjacent card, or scroll container.
(node.path == card.path || node.className == "android.widget.ImageView") &&
cardFor(node)?.path == card.path && contains(card.bounds, node.bounds)
}
}
}
@@ -60,6 +60,14 @@ data class AgentTask(
val status: String,
val source: String,
val replacementOriginType: String,
val imageSearch: ImageSearchImage?,
)
data class ImageSearchImage(
val imageUrl: String,
val mediaType: String,
val sizeBytes: Long,
val sha256: String,
)
data class CurrentPageIdentity(
@@ -84,7 +92,6 @@ data class PurchaseAgentTask(
val mappedColor: String,
val mappedSize: String,
val specResolutionAllowed: Boolean,
val specRematchAllowed: Boolean,
val quantity: Long,
val minUnitPriceCent: Long,
val maxUnitPriceCent: Long,
@@ -454,16 +461,6 @@ class AgentApiClient(private val serverUrl: String) {
requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token))
}
fun rematchPurchaseTask(taskId: Long, attemptId: String, probedSpecs: String, token: String): PurchaseAgentTask {
val payload = JSONObject().put("requestId", UUID.randomUUID().toString())
.put("taskAttemptId", attemptId).put("resultType", "spec_rematch_completed")
.put("probedSpecs", JSONObject(probedSpecs))
val data = requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token)).getJSONObject("data")
val pending = purchaseTask(data)
val claimed = claimPurchaseTask(taskId, UUID.randomUUID().toString(), token)
return startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
}
fun collectionHistory(token: String, page: Int, status: String?, taskNo: String?, days: Int = 30, pageSize: Int = 20): HistoryPage<CollectionHistoryItem> {
val data = requireNotNull(request("GET", historyPath("/api/agent/v1/collection-tasks", page, status, taskNo, days, pageSize), null, token)).getJSONObject("data")
return HistoryPage(
@@ -595,6 +592,7 @@ class AgentApiClient(private val serverUrl: String) {
status = data.getString("status"),
source = data.optString("source", "admin"),
replacementOriginType = data.optString("replacementOriginType"),
imageSearch = data.optJSONObject("imageSearch")?.let { image -> ImageSearchImage(image.getString("imageUrl"), image.getString("mediaType"), image.getLong("sizeBytes"), image.getString("sha256")) },
)
private fun purchaseTask(data: JSONObject) = PurchaseAgentTask(
@@ -611,7 +609,6 @@ class AgentApiClient(private val serverUrl: String) {
mappedColor = data.optString("mappedColor"),
mappedSize = data.optString("mappedSize"),
specResolutionAllowed = data.optBoolean("specResolutionAllowed", false),
specRematchAllowed = data.optBoolean("specRematchAllowed", false),
quantity = data.getLong("quantity"),
minUnitPriceCent = data.getLong("minUnitPriceCent"),
maxUnitPriceCent = data.getLong("maxUnitPriceCent"),
@@ -1,95 +0,0 @@
package cn.ilapage.goauto.agent.persistence
import cn.ilapage.goauto.agent.automation.FreshActionResult
import cn.ilapage.goauto.agent.automation.FreshClickReason
import java.io.File
import org.json.JSONObject
data class PurchaseDiagnosticContext(
val taskId: Long,
val attemptId: String,
val deviceId: Long,
val ruleHash: String,
val agentVersion: String,
val phase: String,
)
/** Private, bounded diagnostics only. Neither raw evidence nor write errors escape this store. */
class PurchaseDiagnosticStore(private val directory: File, private val now: () -> Long = System::currentTimeMillis) {
@Synchronized
fun record(context: PurchaseDiagnosticContext, elapsedMs: Long, evidence: String): Boolean = runCatching {
require(context.taskId > 0 && context.deviceId > 0 && ID.matches(context.attemptId))
require(HASH.matches(context.ruleHash) && VERSION.matches(context.agentVersion))
require(context.phase in setOf("purchase", "spec_probe"))
val fields = sanitize(evidence)
if (fields.length() == 0) return false
val timestamp = now()
val line = JSONObject().put("taskId", context.taskId).put("attemptId", context.attemptId)
.put("deviceId", context.deviceId).put("ruleHash", context.ruleHash)
.put("agentVersion", context.agentVersion).put("phase", context.phase)
.put("timestamp", timestamp).put("elapsedMs", elapsedMs.coerceAtLeast(0))
.put("evidence", fields).toString()
check(directory.isDirectory || directory.mkdirs())
val file = File(directory, "${context.taskId}_${context.attemptId}.jsonl")
file.appendText(line + "\n", Charsets.UTF_8)
val lines = file.readLines(Charsets.UTF_8)
if (lines.size > MAX_EVENTS) file.writeText(lines.takeLast(MAX_EVENTS).joinToString("\n", postfix = "\n"), Charsets.UTF_8)
file.setLastModified(timestamp)
prune(timestamp)
true
}.getOrDefault(false)
@Synchronized
fun prune(timestamp: Long = now()) {
runCatching {
val files = directory.listFiles()?.filter { it.isFile && FILE_NAME.matches(it.name) }.orEmpty()
files.filter { it.lastModified() < timestamp - RETENTION_MS }.forEach { it.delete() }
files.filter(File::exists).sortedWith(compareByDescending<File> { it.lastModified() }.thenBy { it.name })
.drop(MAX_ATTEMPTS).forEach { it.delete() }
}
}
companion object {
const val MAX_ATTEMPTS = 5
const val MAX_EVENTS = 128
const val RETENTION_MS = 7L * 24 * 60 * 60 * 1000
private val ID = Regex("[A-Za-z0-9-]{1,80}")
private val HASH = Regex("[0-9a-f]{64}")
private val VERSION = Regex("[0-9]+\\.[0-9]+\\.[0-9]+")
private val FILE_NAME = Regex("[1-9][0-9]*_[A-Za-z0-9-]{1,80}\\.jsonl")
private val numbers = setOf("specEntryCandidates", "explicit", "nested", "bottomPurchase", "entryReadyWaitPolls",
"entryReadyWaitMillis", "scrollables", "headings", "options", "entryWaitPolls", "entryWaitMillis",
"addressEditorCandidates", "addressEditorPolls", "savedLegacySubmitCandidates", "savedAddressCards",
"savedAddressTargets", "savedScrollCandidates", "savedConfirmationPolls", "reusePolls")
private val booleans = setOf("panelAlreadyOpen", "reviewPage", "pageEvidence", "pageChanged", "summary", "quantity",
"orderAction", "closeControl", "paymentArea", "savedAddressMatched", "savedPanelMatched", "handoffPresent", "productReused")
private val enums = mapOf(
"reuseReason" to setOf("legacy_evidence", "page_problem", "page_changed", "handoff_ready", "evidence_timeout"),
"event" to setOf("started"),
"entrySource" to setOf("unknown", "explicit_selection", "nested_selection", "bottom_purchase", "bottom_purchase_rightmost"),
"entryClick" to FreshActionResult.values().map { it.name }.toSet(),
"entryReason" to FreshClickReason.values().map { it.name }.toSet(),
"gesture" to FreshActionResult.values().map { it.name }.toSet(),
"entryRecovery" to setOf("rejected", "attempted"),
"type" to setOf("UNKNOWN", "NORMAL_SCROLLABLE", "NON_SCROLLABLE_CONFIRMATION", "ORDER_CONFIRMATION", "QUICK_CONFIRMATION"),
"reason" to setOf("identity_missing", "identity_changed", "product_context_missing", "page_problem", "panel_open",
"close_control", "payment_area", "order_action", "quantity_controls", "selection_summary", "title_missing",
"title_changed", "entry_missing", "entry_source_missing", "entry_source_changed", "entry_label_changed",
"entry_ambiguous", "rule_alias_mismatch", "entry_unavailable", "eligible"),
)
internal fun sanitize(evidence: String): JSONObject {
val result = JSONObject()
evidence.take(4096).split(';').take(40).forEach { part ->
val key = part.substringBefore('=')
val value = part.substringAfter('=', "")
when {
key in numbers && value.matches(Regex("[0-9]{1,8}")) -> result.put(key, value.toLong())
key in booleans && value in setOf("true", "false") -> result.put(key, value == "true")
value in enums[key].orEmpty() -> result.put(key, value)
}
}
return result
}
}
}
@@ -18,10 +18,6 @@ import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import cn.ilapage.goauto.agent.BuildConfig
import cn.ilapage.goauto.agent.automation.PddCollectionReturnNavigator
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticContext
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticStore
import java.io.File
import cn.ilapage.goauto.agent.ClipboardRelayActivity
import cn.ilapage.goauto.agent.MainActivity
import cn.ilapage.goauto.agent.R
@@ -87,7 +83,6 @@ class AgentForegroundService : Service() {
private val collectionCooldownWakeActive = AtomicBoolean(false)
private val currentPageResultNotificationUntil = AtomicLong(0L)
private val idleReturn = IdleReturnCoordinator()
private val purchaseProbeHandoff = PurchaseProbeHandoff()
private var taskWakeLock: PowerManager.WakeLock? = null
private var cooldownWakeLock: PowerManager.WakeLock? = null
private var collectionCooldownWakeLock: PowerManager.WakeLock? = null
@@ -96,7 +91,6 @@ class AgentForegroundService : Service() {
private lateinit var settingsStore: AgentSettingsStore
private lateinit var stateStore: AgentStateStore
private lateinit var purchaseStore: PurchaseTaskStore
private lateinit var purchaseDiagnostics: PurchaseDiagnosticStore
private lateinit var diagnosticStore: AgentDiagnosticStore
private lateinit var diagnosticRecorder: SafeAgentDiagnosticRecorder
private lateinit var connectivityManager: ConnectivityManager
@@ -115,8 +109,6 @@ class AgentForegroundService : Service() {
// Wake locks and cooldown tickets are process-local. Never restore a stale UI flag.
stateStore.setKeepScreenOn(false)
purchaseStore = PurchaseTaskStore(this)
purchaseDiagnostics = PurchaseDiagnosticStore(File(filesDir, "purchase_diagnostics"))
runCatching { diagnosticExecutor.execute { purchaseDiagnostics.prune() } }
diagnosticStore = AgentDiagnosticStore(this)
diagnosticRecorder = SafeAgentDiagnosticRecorder(
persist = { event ->
@@ -284,7 +276,7 @@ class AgentForegroundService : Service() {
evaluateIdleReturn()
return "$MANUAL_COLLECTION_COOLDOWN_PREFIX${CollectionCooldownPolicy.remainingSeconds(System.currentTimeMillis(), ticket)}"
}
TaskDispatchDecision.CHECK_COLLECTION -> purchaseProbeHandoff.clear()
TaskDispatchDecision.CHECK_COLLECTION -> Unit
}
val task = api.nextTask(token)
if (task == null) {
@@ -292,7 +284,6 @@ class AgentForegroundService : Service() {
return MANUAL_EMPTY
}
cancelIdleReturn("收到新的采集任务")
purchaseProbeHandoff.clear()
if (!taskMutex.tryAcquire(task.taskId)) return MANUAL_BUSY
if (task.status == "running") runningTaskId.set(task.taskId)
stateStore.setActiveTask(task.taskId, "collection")
@@ -359,16 +350,11 @@ class AgentForegroundService : Service() {
}
runningTaskId.set(task.taskId)
stateStore.setActiveTask(task.taskId, "collection")
val continuous = ContinuousCollectionPolicy.applies(
settingsStore.continuousCollection(), task.source, task.attemptNumber, task.replacementOriginType,
)
val outcome = executeTask(api, task, credentials.token, continuous)
val outcome = executeTask(api, task, credentials.token)
publishCurrentPageResult(
task.taskId,
if (outcome.successful) {
if (continuous) {
outcome.message ?: "采集已结束,请查看采集记录。"
} else if (task.replacementOriginType.isNotBlank()) {
if (task.replacementOriginType.isNotBlank()) {
"已替换,正在匹配规格"
} else {
"临时采集任务 #${task.taskId} 已结束,请查看采集记录。"
@@ -464,7 +450,7 @@ class AgentForegroundService : Service() {
val claimed = if (initial.status == "pending") {
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
} else initial
var task = if (claimed.status == "pending") {
val task = if (claimed.status == "pending") {
api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
} else claimed
check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" }
@@ -492,44 +478,15 @@ class AgentForegroundService : Service() {
if (accessibility == null) {
PurchaseExecutionOutcome("failed", "ACCESSIBILITY_NOT_READY", "GoAuto 无障碍服务未开启")
} else {
val diagnosticDeviceId = runCatching { identityStore.credentials()?.deviceId ?: 0L }.getOrDefault(0L)
val diagnosticAttempt = task.taskAttemptId.takeIf { it.matches(Regex("^[a-zA-Z0-9-]{1,80}$")) } ?: "invalid"
val diagnosticContext = PurchaseDiagnosticContext(task.taskId, task.taskAttemptId, diagnosticDeviceId,
snapshotHash, BuildConfig.VERSION_NAME, task.phase)
val diagnosticStarted = SystemClock.elapsedRealtime()
val handoffKey = PurchaseProbeHandoff.Key(task.taskId, task.pddGoodsId, snapshotHash, diagnosticDeviceId)
val handoffActivity = purchaseProbeHandoff.consume(handoffKey, task.phase,
accessibility.currentForegroundRevision(), diagnosticStarted)
val recordDiagnostic: (String) -> Unit = { evidence ->
val elapsed = SystemClock.elapsedRealtime() - diagnosticStarted
runCatching { diagnosticExecutor.execute { purchaseDiagnostics.record(diagnosticContext, elapsed, evidence) } }
Unit
}
recordDiagnostic("event=started")
PurchaseRehearsalExecutor(
probeHandoffActivity = handoffActivity,
onProbeHandoff = { activity -> purchaseProbeHandoff.remember(handoffKey, activity,
accessibility.currentForegroundRevision(), SystemClock.elapsedRealtime()) },
driver = accessibility,
rematchSpecs = { execution ->
val raw = collectPurchaseProbe(accessibility, task, parsedRule)
if (raw == null) null else {
val refreshed = api.rematchPurchaseTask(task.taskId, task.taskAttemptId, raw, token)
task = refreshed
execution.copy(mappedColor = refreshed.mappedColor, mappedSize = refreshed.mappedSize,
specRematchAllowed = refreshed.specRematchAllowed)
}
},
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) },
stepChanged = { step ->
lastStep.set(step)
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
},
panelDiagnostic = { evidence ->
recordDiagnostic(evidence)
Log.i("GoAutoPurchasePanel", "task=${task.taskId};attempt=$diagnosticAttempt;device=$diagnosticDeviceId;rule=$snapshotHash;$evidence")
},
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
beforeOrderSubmit = { evidence ->
val boundaryRequestId = UUID.randomUUID().toString()
val finalEvidence = JSONObject()
@@ -558,7 +515,6 @@ class AgentForegroundService : Service() {
minUnitPriceCent = task.minUnitPriceCent,
maxUnitPriceCent = task.maxUnitPriceCent,
addressSuffix = task.addressSuffix,
specRematchAllowed = task.specRematchAllowed,
),
parsedRule,
PurchaseAgentCapabilities.supported,
@@ -568,7 +524,6 @@ class AgentForegroundService : Service() {
}
val requestId = UUID.randomUUID().toString()
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
if (outcome.resultType != "spec_probe_completed") purchaseProbeHandoff.clear()
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
resultSafelyStored = true
PurchaseResultBubblePolicy.create(
@@ -589,10 +544,7 @@ class AgentForegroundService : Service() {
} catch (error: Exception) {
stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true)
} finally {
if (!resultSafelyStored) {
purchaseProbeHandoff.clear()
cancelIdleReturn("采购结果未安全保存")
}
if (!resultSafelyStored) cancelIdleReturn("采购结果未安全保存")
releaseTaskWakeLock()
}
}
@@ -679,29 +631,11 @@ class AgentForegroundService : Service() {
api: AgentApiClient,
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String,
continuous: Boolean = false,
): TaskExecutionSummary {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
return try {
val returnCount = settingsStore.continuousReturnCount()
val outcome = executeTaskWhileAwake(api, initialTask, token, continuous)
if (!continuous) outcome else {
cancelIdleReturn("连续采集保留 PDD 页面")
ContinuousCollectionPolicy.finish(continuous, outcome) {
val driver = GoAutoAccessibilityService.instance ?: return@finish false
val navigator = PddCollectionReturnNavigator(driver, driver::openAgentPreservingTab, driver::currentPackage)
val returned = navigator.returnToAgent(returnCount)
runCatching {
val hash = java.security.MessageDigest.getInstance("SHA-256")
.digest(initialTask.ruleSnapshot.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
Log.i("GoAutoCollector", "task=${initialTask.taskId};attempt=${initialTask.attemptNumber};device=${stateStore.read().deviceId};rule=$hash;continuousReturn=$returned;configuredBacks=$returnCount;reason=${navigator.reason};actions=${navigator.actions}")
}
returned
}.also { result ->
if (result.successful) runCatching { stateStore.update("ONLINE", result.message.orEmpty(), tokenStored = true) }
}
}
executeTaskWhileAwake(api, initialTask, token)
} finally {
releaseTaskWakeLock()
}
@@ -711,7 +645,6 @@ class AgentForegroundService : Service() {
api: AgentApiClient,
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String,
continuous: Boolean = false,
): TaskExecutionSummary {
var started = initialTask.status == "running"
var resultSafelySubmitted = false
@@ -775,6 +708,8 @@ class AgentForegroundService : Service() {
).collect(identity.goodsId, rule)
if (!collection.successful) throw TaskFailure(collection.code, collection.message)
CollectionExecution(requireNotNull(collection.payload), collection.colorImages)
} else if (task.source == "image_search") {
throw TaskFailure("IMAGE_SEARCH_AUTOMATION_UNAVAILABLE", "当前 Agent 版本尚未接入 PDD 图搜自动化,请使用普通采集任务")
} else if (rule.schemaVersion == 2) {
val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) }
val collection = PddDetailEntryRunner(
@@ -818,25 +753,25 @@ class AgentForegroundService : Service() {
}
}
stateStore.update("ONLINE", "任务 #${task.taskId} 已提交:${result.status}", tokenStored = true)
beginPostCollectionCooldowns(returnToAgent = !continuous)
TaskExecutionSummary(successful = true, resultStatus = result.status)
beginPostCollectionCooldowns()
TaskExecutionSummary(successful = true)
} catch (error: TaskFailure) {
if (started) {
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, error.code, error.message ?: "采集失败")
if (resultSafelySubmitted) beginPostCollectionCooldowns(returnToAgent = !continuous)
if (resultSafelySubmitted) beginPostCollectionCooldowns()
}
TaskExecutionSummary(false, error.code, error.message)
} catch (error: AgentApiException) {
if (error.code == "REPLACEMENT_ACTIVATION_FAILED") {
resultSafelySubmitted = true
beginPostCollectionCooldowns(returnToAgent = !continuous)
beginPostCollectionCooldowns()
}
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
TaskExecutionSummary(false, error.code, error.message)
} catch (error: Exception) {
if (started) {
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, "AGENT_EXECUTION_ERROR", error.message ?: "Android 执行异常")
if (resultSafelySubmitted) beginPostCollectionCooldowns(returnToAgent = !continuous)
if (resultSafelySubmitted) beginPostCollectionCooldowns()
}
TaskExecutionSummary(false, "AGENT_EXECUTION_ERROR", error.message)
} finally {
@@ -895,8 +830,8 @@ class AgentForegroundService : Service() {
executor.schedule(::triggerSync, IdleReturnCoordinator.DEFAULT_COOLDOWN_MILLIS, TimeUnit.MILLISECONDS)
}
private fun beginPostCollectionCooldowns(returnToAgent: Boolean = true) {
if (returnToAgent) beginIdleReturnCooldown() else cancelIdleReturn("连续采集不自动返回 Agent")
private fun beginPostCollectionCooldowns() {
beginIdleReturnCooldown()
val ticket = CollectionCooldownPolicy.arm(
System.currentTimeMillis(),
settingsStore.collectionIntervalRange(),
@@ -1215,3 +1150,9 @@ class AgentForegroundService : Service() {
}
private class TaskFailure(val code: String, message: String) : Exception(message)
private data class TaskExecutionSummary(
val successful: Boolean,
val code: String? = null,
val message: String? = null,
)
@@ -1,7 +1,6 @@
package cn.ilapage.goauto.agent.service
import android.content.Context
import android.content.SharedPreferences
import cn.ilapage.goauto.agent.BuildConfig
import cn.ilapage.goauto.agent.network.ServerUrlPolicy
import kotlin.random.Random
@@ -114,38 +113,14 @@ internal object TaskDispatchPolicy {
}
}
class AgentSettingsStore internal constructor(private val preferences: SharedPreferences) {
constructor(context: Context) : this(context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE))
class AgentSettingsStore(context: Context) {
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
fun serverUrl(): String = preferences.getString(SERVER_URL, null)
?: BuildConfig.DEFAULT_SERVER_URL.takeIf { it.isNotBlank() }.orEmpty()
fun deviceName(): String = preferences.getString(DEVICE_NAME, "").orEmpty()
fun continuousCollection(): Boolean = runCatching { preferences.getBoolean(CONTINUOUS_COLLECTION, false) }.getOrDefault(false)
fun continuousReturnCount(): Int = runCatching {
preferences.getInt(CONTINUOUS_RETURN_COUNT, 3).takeIf { it in 1..5 } ?: 3
}.getOrDefault(3)
fun saveContinuousReturnCount(count: Int) {
require(count in 1..5) { "返回次数须为 1~5 次" }
val previous = continuousReturnCount()
if (!preferences.edit().putInt(CONTINUOUS_RETURN_COUNT, count).commit()) {
preferences.edit().putInt(CONTINUOUS_RETURN_COUNT, previous).commit()
error("返回次数未保存,请重试")
}
}
fun saveContinuousCollection(enabled: Boolean) {
val previous = continuousCollection()
if (!preferences.edit().putBoolean(CONTINUOUS_COLLECTION, enabled).commit()) {
// SharedPreferences also updates memory on failed disk commits; restore the displayed value.
preferences.edit().putBoolean(CONTINUOUS_COLLECTION, previous).commit()
error("连续采集设置未保存,请重试")
}
}
fun historyDays(): Int = HistoryRangePolicy.stored(preferences.getInt(HISTORY_DAYS, 7))
fun collectionIntervalRange(): CollectionIntervalRange {
@@ -202,8 +177,6 @@ class AgentSettingsStore internal constructor(private val preferences: SharedPre
const val PREFERENCES = "goauto_agent_settings"
const val SERVER_URL = "server_url"
const val DEVICE_NAME = "device_name"
const val CONTINUOUS_COLLECTION = "continuous_collection"
const val CONTINUOUS_RETURN_COUNT = "continuous_collection_return_count"
const val HISTORY_DAYS = "history_days"
const val COLLECTION_INTERVAL_SECONDS = "collection_interval_seconds"
const val COLLECTION_INTERVAL_START_SECONDS = "collection_interval_start_seconds"
@@ -1,27 +0,0 @@
package cn.ilapage.goauto.agent.service
/** Only the foreground, newly requested temporary collection opts in. */
internal object ContinuousCollectionPolicy {
fun applies(enabled: Boolean, source: String, attemptNumber: Int, replacementOrigin: String): Boolean =
enabled && source == "agent_current_page" && attemptNumber == 1 && replacementOrigin.isBlank()
fun shouldReturn(enabledForTask: Boolean, submitted: Boolean, status: String?): Boolean =
enabledForTask && submitted && status == "completed"
fun finish(enabledForTask: Boolean, outcome: TaskExecutionSummary, navigate: () -> Boolean): TaskExecutionSummary {
if (!enabledForTask || !outcome.successful) return outcome
if (!shouldReturn(enabledForTask, outcome.successful, outcome.resultStatus)) {
return outcome.copy(message = "采集部分成功,已保留现场,请查看采集记录。")
}
val returned = runCatching(navigate).getOrDefault(false)
return outcome.copy(message = if (returned) "采集已完成,返回操作已结束,已切回 Agent。"
else "采集已完成,自动返回未完成,请手动处理当前页面并打开 Agent。")
}
}
internal data class TaskExecutionSummary(
val successful: Boolean,
val code: String? = null,
val message: String? = null,
val resultStatus: String? = null,
)
@@ -1,23 +0,0 @@
package cn.ilapage.goauto.agent.service
/** One-use, process-local recognition context; never contains nodes or selection proofs. */
internal class PurchaseProbeHandoff {
data class Key(val taskId: Long, val goodsId: String, val ruleHash: String, val deviceId: Long)
private data class Ticket(val key: Key, val activity: String, val revision: Long, val time: Long)
private var ticket: Ticket? = null
@Synchronized fun remember(key: Key, activity: String, revision: Long, now: Long) {
ticket = Ticket(key, activity, revision, now)
}
@Synchronized fun consume(key: Key, phase: String, revision: Long, now: Long): String? {
val previous = ticket
ticket = null
return previous?.takeIf {
phase == "purchase" && it.key == key && it.revision == revision &&
now - it.time in 0..180_000 && it.activity.isNotBlank()
}?.activity
}
@Synchronized fun clear() { ticket = null }
}
@@ -34,7 +34,6 @@ import cn.ilapage.goauto.agent.update.AgentAppUpdateManager
import cn.ilapage.goauto.agent.update.UpdateCheckResult
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import java.util.concurrent.Executors
@@ -65,10 +64,6 @@ class AgentSettingsFragment : Fragment() {
private lateinit var collectionIntervalEndLayout: TextInputLayout
private lateinit var collectionIntervalEndInput: TextInputEditText
private lateinit var collectionIntervalFeedback: TextView
private lateinit var continuousCollectionSwitch: SwitchMaterial
private lateinit var continuousReturnCountButton: MaterialButton
private lateinit var continuousCollectionFeedback: TextView
private var refreshingContinuousCollection = false
private lateinit var historyDaysLayout: TextInputLayout
private lateinit var historyDaysInput: TextInputEditText
private lateinit var historySyncButton: MaterialButton
@@ -262,69 +257,6 @@ class AgentSettingsFragment : Fragment() {
collectionIntervalFeedback.setPadding(0, context.dp(8), 0, 0)
addView(collectionIntervalFeedback)
}))
addView(context.card(context.cardColumn().apply {
continuousCollectionSwitch = SwitchMaterial(context).apply {
// Use the switch style supplied by our MaterialComponents (M2) theme.
showText = false
textOn = "开启"
textOff = "关闭"
isClickable = true
isFocusable = true
text = "连续采集"
textSize = 18f
setTextColor(context.getColor(R.color.agent_text))
minHeight = context.dp(48)
isChecked = settingsStore.continuousCollection()
setOnCheckedChangeListener { _, enabled ->
if (!refreshingContinuousCollection) {
val current = stateStore.read()
val result = runCatching {
check(current.code != "BUSY" && current.currentTaskId == null) { "任务结束后可修改" }
settingsStore.saveContinuousCollection(enabled)
}
refreshingContinuousCollection = true
isChecked = settingsStore.continuousCollection()
refreshingContinuousCollection = false
continuousCollectionFeedback.text = result.fold(
onSuccess = { "" },
onFailure = { "未保存:${it.message ?: "请重试"}" },
)
continuousCollectionFeedback.visibility = if (continuousCollectionFeedback.text.isEmpty()) View.GONE else View.VISIBLE
refreshContinuousReturnCount()
}
}
}
addView(continuousCollectionSwitch, fullWidth())
continuousReturnCountButton = MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
minHeight = context.dp(48)
setTextColor(context.getColor(R.color.agent_text))
setOnClickListener {
MaterialAlertDialogBuilder(context)
.setTitle("返回次数")
.setSingleChoiceItems(arrayOf("1 次", "2 次", "3 次", "4 次", "5 次"), settingsStore.continuousReturnCount() - 1) { dialog, which ->
val result = runCatching {
val current = stateStore.read()
check(current.code != "BUSY" && current.currentTaskId == null) { "任务结束后可修改" }
check(settingsStore.continuousCollection()) { "请先开启连续采集" }
settingsStore.saveContinuousReturnCount(which + 1)
}
continuousCollectionFeedback.text = result.fold(onSuccess = { "" }, onFailure = { "未保存:${it.message ?: "请重试"}" })
continuousCollectionFeedback.visibility = if (result.isSuccess) View.GONE else View.VISIBLE
refreshContinuousReturnCount()
dialog.dismiss()
}
.setNegativeButton("取消", null)
.show()
}
}
addView(continuousReturnCountButton, fullWidth(8))
refreshContinuousReturnCount()
continuousCollectionFeedback = context.label("", 14f, context.getColor(R.color.agent_text_muted)).apply {
visibility = View.GONE
accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
}
addView(continuousCollectionFeedback, fullWidth(8))
}))
addView(context.card(context.cardColumn().apply {
addView(context.label("任务记录", 18f, context.getColor(R.color.agent_text), true))
val syncRow = LinearLayout(context).apply {
@@ -722,23 +654,11 @@ class AgentSettingsFragment : Fragment() {
return normalizedUrl
}
private fun refreshContinuousReturnCount() {
val current = stateStore.read()
continuousReturnCountButton.text = "返回次数:${settingsStore.continuousReturnCount()} 次"
continuousReturnCountButton.visibility = if (settingsStore.continuousCollection()) View.VISIBLE else View.GONE
continuousReturnCountButton.isEnabled = current.code != "BUSY" && current.currentTaskId == null
}
private fun refreshDiagnostics() {
if (!isAdded || view == null) return
val context = requireContext()
val state = stateStore.read()
val busy = state.code == "BUSY" || state.currentTaskId != null
continuousCollectionSwitch.isEnabled = !busy
refreshContinuousReturnCount()
if (busy) continuousCollectionFeedback.text = "任务结束后可修改"
else if (continuousCollectionFeedback.text == "任务结束后可修改") continuousCollectionFeedback.text = ""
continuousCollectionFeedback.visibility = if (continuousCollectionFeedback.text.isEmpty()) View.GONE else View.VISIBLE
if (busy && updateDownloading) updateCancelled.set(true)
val editable = SettingsAvailabilityResolver.editable(state.code, state.currentTaskId, testing)
serverInput.isEnabled = editable
@@ -1,82 +0,0 @@
package cn.ilapage.goauto.agent
import android.content.SharedPreferences
import cn.ilapage.goauto.agent.service.AgentSettingsStore
import java.lang.reflect.Proxy
import org.junit.Assert.*
import org.junit.Test
class ContinuousCollectionSettingsTest {
@Test fun `default off persists and survives store recreation`() {
val disk = MemoryPreferences()
val store = AgentSettingsStore(disk.preferences)
assertFalse(store.continuousCollection())
store.saveContinuousCollection(true)
assertTrue(AgentSettingsStore(disk.preferences).continuousCollection())
store.saveContinuousCollection(false)
assertFalse(AgentSettingsStore(disk.preferences).continuousCollection())
}
@Test fun `failed disk commit restores previous in memory preference`() {
val disk = MemoryPreferences()
val store = AgentSettingsStore(disk.preferences)
store.saveContinuousCollection(true)
disk.failNextCommit = true
assertTrue(runCatching { store.saveContinuousCollection(false) }.isFailure)
assertTrue(store.continuousCollection())
assertTrue(AgentSettingsStore(disk.preferences).continuousCollection())
}
@Test fun `return count defaults three and persists valid bounds`() {
val disk = MemoryPreferences()
val store = AgentSettingsStore(disk.preferences)
assertEquals(3, store.continuousReturnCount())
for (count in 1..5) {
store.saveContinuousReturnCount(count)
assertEquals(count, AgentSettingsStore(disk.preferences).continuousReturnCount())
}
for (count in listOf(0, 6)) {
assertTrue(runCatching { store.saveContinuousReturnCount(count) }.isFailure)
assertEquals(5, store.continuousReturnCount())
}
}
@Test fun `return count failed write restores previous and invalid stored value defaults`() {
val disk = MemoryPreferences()
val store = AgentSettingsStore(disk.preferences)
store.saveContinuousReturnCount(2)
disk.failNextCommit = true
assertTrue(runCatching { store.saveContinuousReturnCount(4) }.isFailure)
assertEquals(2, AgentSettingsStore(disk.preferences).continuousReturnCount())
disk.preferences.edit().putInt("continuous_collection_return_count", 99).commit()
assertEquals(3, store.continuousReturnCount())
}
private class MemoryPreferences {
private val values = mutableMapOf<String, Any>()
var failNextCommit = false
val preferences = Proxy.newProxyInstance(SharedPreferences::class.java.classLoader, arrayOf(SharedPreferences::class.java)) { _, method, args ->
when (method.name) {
"getBoolean", "getInt" -> values[args!![0] as String] ?: args[1]
"edit" -> editor()
else -> error("Unexpected preference access ${method.name}")
}
} as SharedPreferences
private fun editor(): SharedPreferences.Editor {
val pending = mutableMapOf<String, Any>()
return Proxy.newProxyInstance(SharedPreferences.Editor::class.java.classLoader, arrayOf(SharedPreferences.Editor::class.java)) { proxy, method, args ->
when (method.name) {
"putBoolean", "putInt" -> { pending[args!![0] as String] = args[1]; proxy }
"commit" -> {
values.putAll(pending)
val success = !failNextCommit
failNextCommit = false
success
}
else -> error("Unexpected preference edit ${method.name}")
}
} as SharedPreferences.Editor
}
}
}
@@ -1,163 +0,0 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import cn.ilapage.goauto.agent.service.ContinuousCollectionPolicy
import cn.ilapage.goauto.agent.service.TaskExecutionSummary
import org.junit.Assert.*
import org.junit.Test
class ContinuousCollectionTest {
private val complete = TaskExecutionSummary(true, resultStatus = "completed")
@Test fun `only new ordinary temporary collection participates`() {
assertTrue(ContinuousCollectionPolicy.applies(true, "agent_current_page", 1, ""))
assertFalse(ContinuousCollectionPolicy.applies(false, "agent_current_page", 1, ""))
for (source in listOf("admin", "purchase")) assertFalse(ContinuousCollectionPolicy.applies(true, source, 1, ""))
assertFalse(ContinuousCollectionPolicy.applies(true, "agent_current_page", 2, ""))
for (origin in listOf("collection", "purchase")) assertFalse(ContinuousCollectionPolicy.applies(true, "agent_current_page", 1, origin))
}
@Test fun `only acknowledged complete result can navigate`() {
for (status in listOf("completed_partial", "failed", "running", null)) assertFalse(ContinuousCollectionPolicy.shouldReturn(true, true, status))
assertFalse(ContinuousCollectionPolicy.shouldReturn(true, false, "completed"))
assertFalse(ContinuousCollectionPolicy.shouldReturn(false, true, "completed"))
assertTrue(ContinuousCollectionPolicy.shouldReturn(true, true, "completed"))
}
@Test fun `failure partial and disabled never invoke navigation`() {
val failed = TaskExecutionSummary(false, "failed", "failure")
assertEquals(failed, ContinuousCollectionPolicy.finish(true, failed) { error("unexpected") })
assertEquals(complete, ContinuousCollectionPolicy.finish(false, complete) { error("unexpected") })
val partial = ContinuousCollectionPolicy.finish(true, complete.copy(resultStatus = "completed_partial")) { error("unexpected") }
assertTrue(partial.message!!.contains("保留现场"))
}
@Test fun `navigation failure never rewrites collection success`() {
for (navigate in listOf<() -> Boolean>({ false }, { error("failure") })) {
val result = ContinuousCollectionPolicy.finish(true, complete, navigate)
assertTrue(result.successful)
assertEquals(complete.code, result.code)
assertEquals("completed", result.resultStatus)
assertTrue(result.message!!.contains("手动"))
}
val success = ContinuousCollectionPolicy.finish(true, complete) { true }
assertTrue(success.message!!.contains("已切回 Agent"))
assertFalse(success.message!!.contains("首页"))
}
@Test fun `configured counts send exact backs even when page is unchanged`() {
for (count in 1..5) {
val d = Driver()
val waits = mutableListOf<Long>()
val navigator = d.navigator { waits += it }
assertTrue(navigator.returnToAgent(count))
assertEquals(count, d.backs)
assertEquals(count, navigator.actions)
assertEquals(1, d.opens)
assertEquals(List(count + 1) { 500L }, waits)
assertEquals("count_completed", navigator.reason)
}
}
@Test fun `invalid counts do not navigate`() {
for (count in listOf(-1, 0, 6, Int.MAX_VALUE)) {
val d = Driver()
assertFalse(d.navigator().returnToAgent(count))
assertEquals(0, d.backs + d.opens)
}
}
@Test fun `leaving pdd stops remaining backs and returns to agent`() {
val d = Driver().apply { leaveAfter = 1 }
val navigator = d.navigator()
assertTrue(navigator.returnToAgent(3))
assertEquals(1, d.backs)
assertEquals(1, d.opens)
assertEquals("left_pdd", navigator.reason)
}
@Test fun `already outside pdd sends no back`() {
val d = Driver().apply { pkg = "launcher" }
assertTrue(d.navigator().returnToAgent(3))
assertEquals(0, d.backs)
assertEquals(1, d.opens)
}
@Test fun `back failure stops without retry but can return to agent`() {
val d = Driver().apply { backSuccess = false }
val navigator = d.navigator()
assertTrue(navigator.returnToAgent(3))
assertEquals(1, d.backs)
assertEquals("back_failed", navigator.reason)
}
@Test fun `risk pages preserve scene including after last back`() {
for (after in listOf(0, 1)) {
val d = Driver().apply { riskAfter = after }
val navigator = d.navigator()
assertFalse(navigator.returnToAgent(1))
assertEquals(after, d.backs)
assertEquals(0, d.opens)
assertEquals("unsafe_page", navigator.reason)
}
}
@Test fun `switching foreground during capture never sends back to other app`() {
val d = Driver().apply { changeDuringCapture = true }
assertTrue(d.navigator().returnToAgent(3))
assertEquals(0, d.backs)
}
@Test fun `agent launch and foreground confirmation failures are explicit`() {
for (launch in listOf(false, true)) {
val d = Driver().apply { launchSuccess = launch; confirmAgent = false }
val navigator = d.navigator()
assertFalse(navigator.returnToAgent(1))
assertEquals(if (launch) "agent_not_confirmed" else "agent_launch_failed", navigator.reason)
}
}
@Test fun `missing activity or capture exception does not send back`() {
for (throws in listOf(false, true)) {
val d = Driver().apply { missingActivity = true; throwCapture = throws }
assertFalse(d.navigator().returnToAgent(3))
assertEquals(0, d.backs + d.opens)
}
}
private class Driver : PddCollectorDriver {
var pkg = "com.xunmeng.pinduoduo"
var backs = 0
var opens = 0
var leaveAfter = Int.MAX_VALUE
var riskAfter = Int.MAX_VALUE
var backSuccess = true
var launchSuccess = true
var confirmAgent = true
var missingActivity = false
var throwCapture = false
var changeDuringCapture = false
fun navigator(pause: (Long) -> Unit = {}) = PddCollectionReturnNavigator(this, {
opens++
if (launchSuccess && confirmAgent) pkg = "cn.ilapage.goauto.agent"
launchSuccess
}, { pkg }, pause)
override fun capture(): UiSnapshot {
if (throwCapture) error("capture failed")
val snapshot = UiSnapshot(pkg, if (missingActivity) null else "PddActivity", if (backs >= riskAfter) listOf(
SnapshotNode("risk", null, "订单详情", null, null, "android.widget.TextView", NodeBounds(0, 0, 100, 100),
false, false, false, false, true, true),
) else emptyList())
if (changeDuringCapture) pkg = "launcher"
return snapshot
}
override fun back(): Boolean {
backs++
if (backs >= leaveAfter) pkg = "launcher"
return backSuccess
}
override fun clickFresh(target: SnapshotNode) = error("must not click page controls")
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = error("must not swipe")
override fun pullDownGoodsPage() = error("must not pull down")
}
}
@@ -27,66 +27,6 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PddProductDetailCollectorTest {
@Test fun `purchase entry can recognize one heading only with combined structure`() {
val snapshot = singleDimensionPanel()
assertFalse(PddScreenParser.parse(snapshot, config(), GOODS_ID, null).specPanelOpen)
assertEquals(SpecPanelType.NORMAL_SCROLLABLE,
PddScreenParser.parse(snapshot, config(), GOODS_ID, null, purchaseEntryContext = true).specPanelType)
for (missing in listOf("close", "plus", "minus", "info/quantity", "payment", "submit", "scroll/color-title", "scroll/color")) {
val changed = snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == missing })
assertFalse(missing, PddScreenParser.parse(changed, config(), GOODS_ID, null, purchaseEntryContext = true).specPanelOpen)
}
assertFalse(PddScreenParser.parse(snapshot.copy(packageName = "other"), config(), GOODS_ID, null, purchaseEntryContext = true).specPanelOpen)
assertFalse(PddScreenParser.parse(snapshot.copy(activityName = null), config(), GOODS_ID, null, purchaseEntryContext = true).specPanelOpen)
assertFalse(PddScreenParser.parse(snapshot, config(), "", null, purchaseEntryContext = true).specPanelOpen)
}
private fun singleDimensionPanel(): UiSnapshot {
val snapshot = prefixlessPanel()
return snapshot.copy(nodes = snapshot.nodes.filterNot {
it.path.startsWith("scroll/size") || it.path == "info/summary"
}.map {
if (it.path == "root") it.copy(resourceId = "android:id/content", className = "android.widget.FrameLayout") else it
})
}
@Test fun `single dimension without summary is recognized only during collection`() {
for (heading in listOf("颜色分类", "尺码")) {
val snapshot = singleDimensionPanel().let { original ->
original.copy(nodes = original.nodes.map {
if (it.path == "scroll/color-title") it.copy(text = heading) else it
})
}
assertFalse(PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()).specPanelOpen)
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence(), collectionContext = true)
assertEquals(SpecPanelType.NORMAL_SCROLLABLE, parsed.specPanelType)
assertEquals("scroll", parsed.specPanelContainer?.path)
assertEquals(1, parsed.dimensions.size)
assertEquals(null, parsed.selectedSummary)
assertFalse(parsed.hasSelectionSummary)
}
}
@Test fun `single dimension collection requires all structural and page evidence`() {
val snapshot = singleDimensionPanel()
for (missing in listOf("close", "plus", "minus", "info/quantity", "payment", "submit", "scroll/color-title", "scroll/color", "root")) {
val changed = snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == missing })
assertFalse(missing, PddScreenParser.parse(changed, config(), GOODS_ID, evidence(), collectionContext = true).specPanelOpen)
}
val secondScroll = node("other-scroll", "", 0, 500, 1080, 700, scrollable = true)
val secondSubmit = node("other-submit", "提交订单", 10, 2200, 300, 2300, clickable = true)
for (changed in listOf(
snapshot.copy(nodes = snapshot.nodes + secondScroll),
snapshot.copy(nodes = snapshot.nodes + secondSubmit),
snapshot.copy(packageName = "example.other"),
snapshot.copy(activityName = "example.OtherActivity"),
snapshot.copy(nodes = snapshot.nodes + node("captcha", "请完成安全验证", 0, 0, 600, 100)),
)) {
assertFalse(PddScreenParser.parse(changed, config(), GOODS_ID, evidence(), collectionContext = true).specPanelOpen)
}
assertFalse(PddScreenParser.parse(snapshot, config(), GOODS_ID, null, collectionContext = true).specPanelOpen)
}
private fun prefixlessPanel(): UiSnapshot = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
node("root", "", 0, 0, 1080, 2376),
node("close", "关闭", 970, 270, 1050, 350, clickable = true),
@@ -18,213 +18,6 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseLiveAutomationTest {
@Test fun `address editor waits for loaded unique stable input after one second transitions`() {
val driver = LiveDriver()
var polls = 0
driver.editTransform = { snapshot ->
polls++
when (polls) {
1 -> snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" })
2 -> snapshot.copy(nodes = snapshot.nodes.map { if (it.path == "editor-address") it.copy(text = "") else it })
else -> snapshot
}
}
val pauses = mutableListOf<Long>()
PurchaseLiveAutomation(driver, pause = { pauses += it }).updateShippingAddress("_cg81")
assertTrue(polls >= 4)
assertEquals("editor-address", driver.lastInputTargetPath)
assertEquals(4, pauses.count { it == 1_000L })
assertEquals(0, driver.submitClicks)
}
@Test fun `persistently ambiguous address editors never receive input`() {
val driver = LiveDriver()
driver.editTransform = { snapshot -> snapshot.copy(nodes = snapshot.nodes + snapshot.nodes.single { it.path == "editor-address" }.copy(path = "duplicate-editor")) }
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code)
assertTrue(error.message!!.contains("candidates=2"))
assertEquals(null, driver.lastInputTargetPath)
assertFalse(driver.clicked.contains("保存地址"))
}
@Test
fun `non scrollable saved panel waits for late address nodes without back or swipe`() {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
var polls = 0
driver.structuredTransform = { s ->
polls++
s.copy(nodes = s.nodes.filterNot {
it.path.startsWith("root/popup/specs") ||
(polls < 8 && it.path.startsWith("root/popup/address"))
})
}
val logs = mutableListOf<String>()
val automation = PurchaseLiveAutomation(driver, pause = {}, diagnostic = logs::add)
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
assertTrue(polls >= 9)
assertEquals(0, driver.scopedSwipes)
assertEquals(0, driver.backCount)
assertEquals(0, driver.submitClicks)
assertTrue(logs.any { it.contains("savedAddressMatched=true") && it.contains("savedScrollCandidates=0") })
assertFalse(logs.any { it.contains("_cg81") || it.contains("138") })
}
@Test
fun `saved address recognizes split dynamic footer outside spec scroller without a swipe`() {
for (label in listOf("复购价,", "专享优惠,")) {
val driver = LiveDriver(savedStructuredFooter = label)
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
automation.submitOrderOnce()
assertEquals(1, driver.submitClicks)
assertEquals(0, driver.scopedSwipes)
assertEquals(0, driver.backCount)
assertTrue(driver.clicked.contains(label))
assertEquals("PURCHASE_SUBMIT_ALREADY_ATTEMPTED",
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
}
}
@Test
fun `one back from address transition can recognize dynamic footer`() {
val driver = LiveDriver(savedStructuredFooter = "复购价,", savedTransitionWithoutLegacyContext = true)
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
assertEquals(1, driver.backCount)
assertEquals(0, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address with missing ambiguous price only or payment footer reports button failure without scrolling`() {
val mutations: List<(UiSnapshot) -> UiSnapshot> = listOf(
{ s -> s.copy(nodes = s.nodes.filterNot { it.path.startsWith("root/popup/footer") }) },
{ s -> s.copy(nodes = s.nodes + s.nodes.filter { it.path.startsWith("root/popup/footer") }.map {
it.copy(path = it.path.replace("footer", "second-footer"), parentPath = it.parentPath?.replace("footer", "second-footer"))
}) },
{ s -> s.copy(nodes = s.nodes.filterNot { it.path == "root/popup/footer/label" }) },
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer") it.copy(clickable = false) else it }) },
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer/label") it.copy(text = "去付款") else it }) },
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer") it.copy(enabled = false) else it }) },
)
for (mutation in mutations) {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
driver.structuredTransform = mutation
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error.code)
assertEquals(0, driver.scopedSwipes)
assertEquals(0, driver.backCount)
assertEquals(0, driver.submitClicks)
}
}
@Test
fun `legacy submit labels retain priority over structure`() {
for (label in listOf("提交订单", "现在买,仅20元", "确认购买")) {
val driver = LiveDriver(savedStructuredFooter = label)
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
automation.submitOrderOnce()
assertEquals(1, driver.submitClicks)
assertEquals(0, driver.scopedSwipes)
}
}
@Test
fun `structural footer cannot bypass final spec quantity price or address verification`() {
val cases = listOf(
input().copy(mappedColor = "白色") to "PURCHASE_SPEC_NOT_MATCHED",
input().copy(quantity = 3) to "PURCHASE_QUANTITY_MISMATCH",
input().copy(maxUnitPriceCent = 100) to "PURCHASE_PRICE_OUT_OF_RANGE",
)
for ((request, code) in cases) {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
val error = runCatching { automation.finalConfirmation(request.copy(addressSuffix = "_cg81"), proof) }
.exceptionOrNull() as PurchaseLiveException
assertEquals(code, error.code)
assertEquals(0, driver.submitClicks)
}
val driver = LiveDriver(savedStructuredFooter = "复购价,")
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
driver.structuredTransform = { s -> s.copy(nodes = s.nodes.filterNot { it.path.endsWith("/address/detail") }) }
assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED",
(runCatching { automation.finalConfirmation(input(), proof) }.exceptionOrNull() as PurchaseLiveException).code)
assertEquals(0, driver.submitClicks)
}
@Test
fun `structural footer needs this execution saved address and unchanged page at submission`() {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
(runCatching { PurchaseLiveAutomation(driver, pause = {}).finalConfirmation(input(), proof) }
.exceptionOrNull() as PurchaseLiveException).code)
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
driver.structuredTransform = { s -> s.copy(nodes = s.nodes.map {
if (it.path.endsWith("footer/label")) it.copy(text = "另一操作") else it
}) }
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
assertEquals(0, driver.submitClicks)
}
@Test
fun `footer duplicate text nodes remain one action and external overlays are not candidates`() {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
driver.structuredTransform = { s -> s.copy(nodes = s.nodes + listOf(
s.nodes.first { it.path == "root/popup/footer/label" }.copy(
path = "root/popup/footer/duplicate-label", bounds = NodeBounds(760, 2203, 950, 2262)),
s.nodes.first { it.path == "root/popup/footer" }.copy(path = "root/overlay", parentPath = "root", text = "悬浮按钮"),
)) }
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
automation.submitOrderOnce()
assertEquals(1, driver.submitClicks)
assertFalse(driver.clicked.contains("悬浮按钮"))
}
@Test
fun `failed revalidation clears structural submit authorization`() {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input(), proof)
assertTrue(runCatching { automation.finalConfirmation(input().copy(quantity = 3), proof) }.isFailure)
assertTrue(runCatching { automation.submitOrderOnce() }.isFailure)
assertEquals(0, driver.submitClicks)
}
@Test
fun `address alone full page ancestor and order or payment pages cannot authorize structural footer`() {
val mutations: List<(UiSnapshot) -> UiSnapshot> = listOf(
{ s -> s.copy(nodes = s.nodes.filterNot { it.path.endsWith("/quantity") || it.path.endsWith("/selected") }) },
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup") it.copy(bounds = NodeBounds(0, 0, 1080, 2354)) else it }) },
{ s -> s.copy(activityName = "com.xunmeng.pinduoduo.app_pay.core.PayActivity") },
{ s -> s.copy(packageName = "example.other") },
{ s -> s.copy(nodes = s.nodes.map { if (it.path.endsWith("/selected")) it.copy(text = "订单详情") else it }) },
)
for (mutation in mutations) {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
driver.structuredTransform = mutation
assertTrue(runCatching { automation.finalConfirmation(input(), proof) }.isFailure)
assertEquals(0, driver.submitClicks)
}
}
@Test
fun `verified spec panel advances through one exact confirm target`() {
val driver = SpecConfirmationDriver()
@@ -348,72 +141,6 @@ class PurchaseLiveAutomationTest {
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address back into short spec panel restores without aspect ratio restriction`() {
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true,
savedPanelBounds = NodeBounds(0, 400, 1080, 1325))
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg81")
assertEquals("_cg81", automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), address).addressSuffix)
assertEquals(1, driver.backCount)
assertEquals(1, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `short saved spec panel still stops when unchanged`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedSpecPanelRecoveryStuck = true,
savedPanelBounds = NodeBounds(0, 400, 1080, 1325))
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(0, driver.backCount)
assertTrue(driver.scopedSwipes in 1..5)
assertEquals(0, driver.submitClicks)
}
@Test
fun `missing and duplicate saved panel scroll regions fail distinctly without gestures`() {
for (count in listOf(0, 2)) {
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedPanelScrollCount = count)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
assertTrue(error.message.orEmpty().contains(if (count == 0) "未找到可用" else "找到多个"))
assertEquals(0, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
}
@Test
fun `saved address back into spec panel restores final evidence with one back`() {
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true)
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg81")
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), address)
assertEquals("_cg81", final.addressSuffix)
assertEquals(1, driver.backCount)
assertEquals(1, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address back into stuck spec panel fails without another back or submit`() {
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true,
savedSpecPanelRecoveryStuck = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(1, driver.backCount)
assertTrue(driver.scopedSwipes in 1..5)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address returning to spec panel is recovered without pressing back`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true)
@@ -918,11 +645,7 @@ class PurchaseLiveAutomationTest {
private val savedTransitionWithoutLegacyContext: Boolean = false,
private val savedTransitionHidesSuffix: Boolean = false,
private val saveReturnsToSpecPanel: Boolean = false,
private val backReturnsToSpecPanel: Boolean = false,
private val savedPanelBounds: NodeBounds = NodeBounds(0, 400, 1080, 2100),
private val savedPanelScrollCount: Int = 1,
private val savedSpecPanelRecoveryStuck: Boolean = false,
private val savedStructuredFooter: String? = null,
private val duplicatePhoneNodesSameCard: Boolean = false,
private val duplicateSemanticAddressCards: Boolean = false,
private val duplicateAddressCards: Boolean = false,
@@ -953,34 +676,16 @@ class PurchaseLiveAutomationTest {
var orderDetailEntryClicks = 0
var postSubmitCaptureCount = 0
val currentPage: String get() = page
var structuredTransform: (UiSnapshot) -> UiSnapshot = { it }
var editTransform: (UiSnapshot) -> UiSnapshot = { it }
override fun capture(): UiSnapshot {
if (submitClicks > 0) {
postSubmitCaptureCount++
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
}
return currentSnapshot().let { if (page == "structured") structuredTransform(it) else if (page == "edit") editTransform(it) else it }
return currentSnapshot()
}
private fun currentSnapshot(): UiSnapshot = when (page) {
"structured" -> snapshot(listOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2354)),
node("root/popup", "", clickable = true, parentPath = "root", bounds = NodeBounds(0, 366, 1080, 2306)),
node("root/popup/address", "", clickable = true, parentPath = "root/popup", bounds = NodeBounds(0, 400, 1080, 530)),
node("root/popup/address/phone", "138****5678", parentPath = "root/popup/address", bounds = NodeBounds(30, 400, 400, 450)),
node("root/popup/address/detail", address, parentPath = "root/popup/address", bounds = NodeBounds(30, 456, 1000, 505)),
node("root/popup/price", "¥20.00", parentPath = "root/popup", bounds = NodeBounds(396, 620, 700, 690)),
node("root/popup/selected", "已选 黑色 XL", parentPath = "root/popup", bounds = NodeBounds(396, 731, 1053, 845)),
node("root/popup/quantity", "2", className = "android.widget.EditText", parentPath = "root/popup", bounds = NodeBounds(462, 881, 540, 959)),
node("root/popup/specs", "", scrollable = true, className = "androidx.recyclerview.widget.RecyclerView", parentPath = "root/popup", bounds = NodeBounds(0, 1132, 1080, 2057)),
node("root/popup/specs/color", "颜色分类", parentPath = "root/popup/specs", bounds = NodeBounds(36, 1157, 287, 1218)),
node("root/popup/payment-method", "微信支付", clickable = true, parentPath = "root/popup", bounds = NodeBounds(0, 2057, 1080, 2156)),
node("root/popup/footer", "", clickable = true, className = "android.widget.FrameLayout", parentPath = "root/popup", bounds = NodeBounds(0, 2159, 1080, 2306)),
node("root/popup/footer/label", requireNotNull(savedStructuredFooter), parentPath = "root/popup/footer", bounds = NodeBounds(353, 2203, 557, 2262)),
node("root/popup/footer/amount", "20.00元", parentPath = "root/popup/footer", bounds = NodeBounds(557, 2203, 727, 2262)),
))
"panel" -> snapshot(listOf(
node("title", "收货地址"),
node("modify-parent", "", clickable = true),
@@ -1003,7 +708,7 @@ class PurchaseLiveAutomationTest {
))
"post-save-spec" -> snapshot(listOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
node("panel", "", scrollable = true, bounds = savedPanelBounds),
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
node("panel-title", "确认款式", bounds = NodeBounds(20, 396, 300, 430)),
node("panel/price", "¥20.00", parentPath = "panel", bounds = NodeBounds(20, 460, 300, 520)),
node("panel/selected", "已选 黑色 XL", parentPath = "panel", bounds = NodeBounds(20, 540, 700, 600)),
@@ -1015,8 +720,7 @@ class PurchaseLiveAutomationTest {
node("panel/confirm", "确定", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 1200, 500, 1280)),
node("submit-parent", "", clickable = true, bounds = NodeBounds(20, 1900, 1000, 2100)),
node("submit", "提交订单", parentPath = "submit-parent", bounds = NodeBounds(520, 1940, 980, 2040)),
).map { if (it.path == "panel" && savedPanelScrollCount == 0) it.copy(enabled = false) else it } +
if (savedPanelScrollCount > 1) listOf(node("panel/duplicate", "", scrollable = true, bounds = savedPanelBounds, parentPath = "panel")) else emptyList())
))
"order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true)))
"order-folded" -> snapshot(listOf(node("status", "待付款"), node("pay", "立即支付", clickable = true)))
"order-folded-payment-activity" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf(
@@ -1095,11 +799,6 @@ class PurchaseLiveAutomationTest {
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicked += target.label
if (target.path == "root/popup/footer/label") {
submitClicks++
page = "order"
return FreshActionResult.SUCCESS
}
when (target.label) {
"138****5678" -> page = "panel"
"修改" -> page = "edit"
@@ -1107,7 +806,6 @@ class PurchaseLiveAutomationTest {
page = when {
saveReturnsToSpecPanel -> "post-save-spec"
savedTransitionWithoutLegacyContext -> "saved-transition"
savedStructuredFooter != null -> "structured"
else -> "panel"
}
}
@@ -1165,7 +863,6 @@ class PurchaseLiveAutomationTest {
page = when (page) {
"chooser" -> "payment"
"payment" -> if (orderEvidenceBelowFold) "order-folded" else "order"
"saved-transition" -> if (savedStructuredFooter != null) "structured" else if (backReturnsToSpecPanel) "post-save-spec" else "confirmation"
else -> "confirmation"
}
return true
@@ -9,94 +9,19 @@ 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.PurchaseSwipeFailureReason
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
import cn.ilapage.goauto.agent.automation.RuleValidationException
import cn.ilapage.goauto.agent.automation.SnapshotNode
import cn.ilapage.goauto.agent.automation.SwipeDirection
import cn.ilapage.goauto.agent.automation.UiSnapshot
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticContext
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticStore
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
import org.json.JSONObject
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseRehearsalExecutorTest {
@Test fun `single heading probe hands recognition to new purchase executor without opening link`() {
val driver = FakePurchaseDriver(singleHeadingOnEntry = true)
var handoff: String? = null
val probe = PurchaseRehearsalExecutor(driver, { true }, { "{}" }, pause = {}, onProbeHandoff = { handoff = it })
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", probe.resultType)
assertTrue(handoff != null)
var opens = 0
val logs = mutableListOf<String>()
PurchaseRehearsalExecutor(driver, { opens++; true }, { null }, pause = {},
probeHandoffActivity = handoff, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(0, opens)
assertTrue(logs.any { it.contains("handoffPresent=true;productReused=true;reusePolls=2;") })
}
@Test fun `handoff tolerates transient empty tree but never reuses another app`() {
for (mode in listOf("transient", "otherApp", "timeout")) {
val original = FakePurchaseDriver(singleHeadingOnEntry = true)
var handoff: String? = null
PurchaseRehearsalExecutor(original, { true }, { "{}" }, pause = {}, onProbeHandoff = { handoff = it })
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
var captures = 0
var opens = 0
val driver = object : PurchaseUiDriver by original {
override fun capture(): UiSnapshot {
captures++
return if (captures <= if (mode == "timeout") 10 else 2) UiSnapshot(if (mode == "otherApp") "cn.ilapage.goauto.agent" else null, null, emptyList()) else original.capture()
}
}
val logs = mutableListOf<String>()
PurchaseRehearsalExecutor(driver, { opens++; true }, { null }, pause = {},
probeHandoffActivity = handoff, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(if (mode == "transient") 0 else 1, opens)
assertTrue(logs.any { it.contains("productReused=${mode == "transient"}") })
if (mode == "timeout") assertTrue(logs.any { it.contains("reusePolls=10;reuseReason=evidence_timeout") })
}
}
@Test fun `safe entry with one visible heading completes probe without reopening`() {
val driver = FakePurchaseDriver(singleHeadingOnEntry = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
}
@get:Rule val diagnosticTemporary = TemporaryFolder()
@Test
fun `entry rejection persists without changing outcome when diagnostic storage fails`() {
for (writable in listOf(true, false)) {
val location = if (writable) diagnosticTemporary.newFolder() else diagnosticTemporary.newFile()
val store = PurchaseDiagnosticStore(location)
val context = PurchaseDiagnosticContext(81, "attempt-14", 7, "a".repeat(64), "0.9.67", "purchase")
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, productOverlay = true,
specTapResult = FreshActionResult.SUCCESS)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {},
panelDiagnostic = { store.record(context, 0, it); Unit })
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
assertEquals(0, driver.specTapCount)
if (writable) {
val events = location.listFiles()!!.single().readLines().map { JSONObject(it).getJSONObject("evidence") }
assertEquals("close_control", events.last().getString("reason"))
assertTrue(events.any { it.optInt("entryWaitPolls") == 30 })
assertTrue(events.any { it.optString("entryClick") == "SUCCESS" })
}
}
}
@Test
fun `color selection then single size heading completes without selecting color again`() {
val driver = FakePurchaseDriver(prefixlessSingleHeadingAfterColor = true)
@@ -173,7 +98,7 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `rule parameters drive aliases and waits while deprecated panel swipes are ignored`() {
fun `rule parameters drive aliases waits and bounded swipes without dangerous clicks`() {
val driver = FakePurchaseDriver()
val pauses = mutableListOf<Long>()
var openCount = 0
@@ -187,7 +112,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(2_000L, outcome.actualUnitPriceCent)
assertEquals(0, openCount)
assertEquals(0, driver.swipeCount)
assertEquals(2, driver.swipeCount)
assertEquals(2L, driver.quantity)
assertTrue(driver.clicked.containsAll(listOf("选择规格", "黑色", "XL")))
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
@@ -839,7 +764,7 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `open spec panel still skips legacy follow-up swipe for confirmed non-scrollable panel`() {
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
@@ -848,86 +773,6 @@ class PurchaseRehearsalExecutorTest {
assertEquals(0, driver.swipeCount)
}
@Test
fun `legacy panel preswipe cannot block visible specs in probe or purchase phase`() {
for (nonScrollable in listOf(false, true)) {
for (phase in listOf("spec_probe", "purchase")) {
val driver = FakePurchaseDriver(nonScrollablePanel = nonScrollable, purchaseSwipeSucceeds = false)
val diagnostics = mutableListOf<String>()
val pauses = mutableListOf<Long>()
var probeCount = 0
val raw = rule().replace("\"type\":\"openSpecPanel\"", "\"type\":\"openSpecPanel\",\"waitAfterMs\":321")
val outcome = PurchaseRehearsalExecutor(
driver, { true }, { probeCount++; "{}" }, pause = pauses::add,
panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = phase), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, if (phase == "spec_probe") "spec_probe_completed" else "rehearsal_completed", outcome.resultType)
assertEquals(0, driver.swipeCount)
assertTrue(pauses.contains(321L))
assertFalse(pauses.contains(1000L))
assertTrue(diagnostics.any { it.contains("reason=spec_panel_on_demand") })
assertEquals(if (phase == "spec_probe") 1 else 0, probeCount)
assertEquals(if (phase == "spec_probe") 0 else 1, driver.clicked.count { it == "黑色" })
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
}
}
}
@Test
fun `non panel mandatory swipes still execute and fail closed`() {
val raw = rule().replace("\"type\":\"selectSpec\"", "\"type\":\"selectSpec\",\"swipeAfter\":{\"direction\":\"up\",\"count\":2,\"durationMs\":500,\"intervalMs\":1000}")
for (succeeds in listOf(false, true)) {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = succeeds)
val diagnostics = mutableListOf<String>()
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = pauses::add, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
assertEquals(if (succeeds) "rehearsal_completed" else "failed", outcome.resultType)
assertEquals(if (succeeds) 2 else 1, driver.swipeCount)
assertEquals(succeeds, pauses.contains(1000L))
if (!succeeds) {
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertTrue(diagnostics.any { it.contains("action=selectSpec") && it.contains("reason=unknown") })
}
}
}
@Test
fun `quick and order confirmation selectors also skip legacy panel preswipe`() {
for (kind in listOf("QUICK_CONFIRMATION", "ORDER_CONFIRMATION")) {
val driver = FakePurchaseDriver(confirmationPanelKind = kind, purchaseSwipeSucceeds = false)
val diagnostics = mutableListOf<String>()
var probed = false
val outcome = PurchaseRehearsalExecutor(
driver, { true }, { probed = true; "{}" }, pause = {}, panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "spec_probe_completed", outcome.resultType)
assertTrue(probed)
assertEquals(0, driver.swipeCount)
assertTrue(diagnostics.any { it.contains("postSwipe=skipped") && it.contains("panel=$kind") })
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") || it == "现在买" })
}
}
@Test
fun `mandatory swipe diagnostics use fixed reasons without spec text`() {
val raw = rule().replace("\"type\":\"selectSpec\"", "\"type\":\"selectSpec\",\"swipeAfter\":{\"direction\":\"up\",\"count\":1,\"durationMs\":500}")
for (reason in PurchaseSwipeFailureReason.values()) {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false, purchaseSwipeFailure = reason)
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertTrue(diagnostics.contains("postSwipe=failed;action=selectSpec;direction=UP;swipeIndex=1;reason=${reason.name.lowercase()}"))
assertFalse(diagnostics.any { it.contains("黑色") || it.contains("XL") })
}
}
@Test(expected = RuleValidationException::class)
fun `ignored legacy panel swipe still rejects invalid rule parameters`() {
PurchaseRuleParser.parse(rule().replace("\"count\":2", "\"count\":0"))
}
@Test
fun `unrecognized opened panel returns only scalar panel evidence`() {
val driver = FakePurchaseDriver(unrecognizedPanel = true)
@@ -957,97 +802,6 @@ class PurchaseRehearsalExecutorTest {
assertTrue(driver.panel)
}
@Test
fun `selected color card duplicate does not trigger another selection tap`() {
val driver = FakePurchaseDriver(duplicateColorCardEvidence = true)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(0, driver.specTapCount)
}
@Test
fun `dynamic product page recovers ineffective and failed entry clicks once`() {
for (reason in listOf(null, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.TARGET_NOT_FOUND)) {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
forcedEntryClickReason = reason, specTapResult = FreshActionResult.SUCCESS)
val logs = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(1, driver.specTapCount)
assertTrue(logs.any { it.contains("entryRecovery=attempted") })
assertFalse(logs.any { it.contains("测试商品") || it.contains("选择规格") })
}
}
@Test
fun `dynamic product page with ineffective gesture reports no effect without second tap`() {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
specTapResult = FreshActionResult.SUCCESS, specTapHasEffect = false)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
assertEquals(1, driver.specTapCount)
}
@Test
fun `dynamic product with close control never receives recovery tap`() {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, productOverlay = true,
specTapResult = FreshActionResult.SUCCESS)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
assertEquals(0, driver.specTapCount)
}
@Test
fun `payment method promotion alone permits one safe product entry retry`() {
for (effect in listOf(true, false)) {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
afterEntryMutation = "payment-promotion", specTapResult = FreshActionResult.SUCCESS, specTapHasEffect = effect)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
if (effect) assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
else assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
assertEquals(1, driver.specTapCount)
}
}
@Test
fun `changed entry identity or unsafe page never receives recovery tap`() {
for (mode in listOf("missing", "duplicate", "activity", "payment", "order")) {
val diagnostics = mutableListOf<String>()
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
afterEntryMutation = mode, specTapResult = FreshActionResult.SUCCESS)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(mode, "failed", outcome.resultType)
assertEquals(mode, 0, driver.specTapCount)
val expected = mapOf("missing" to "entry_missing", "duplicate" to "entry_ambiguous",
"activity" to "identity_changed", "payment" to "payment_area", "order" to "payment_area").getValue(mode)
assertTrue(diagnostics.toString(), diagnostics.any { it.contains("entryRecovery=rejected;reason=$expected;") })
}
}
@Test
fun `unavailable exact color stops without selection click or searching even when selected`() {
for (selected in listOf(null, "黑色")) {
val driver = FakePurchaseDriver(colors = listOf("黑色", "白色"),
unavailableColors = setOf("黑色")).apply { color = selected }
val logs = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode)
assertEquals("目标颜色已售罄或当前不可选,未创建订单", outcome.message)
assertFalse(driver.clicked.contains("黑色"))
assertEquals(0, driver.specTapCount)
assertEquals(0, driver.swipeCount)
assertTrue(logs.any { it == "specLookup=unavailable;dimension=color;reason=exact_target_unavailable" })
assertFalse(logs.any { it.contains("黑色") })
}
}
@Test
fun `unchanged spec entry after action and gesture returns no effect`() {
val driver = FakePurchaseDriver(
@@ -1093,10 +847,10 @@ class PurchaseRehearsalExecutorTest {
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
assertEquals(
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
diagnostics.single { it.startsWith("specEntryCandidates=") },
diagnostics.single(),
)
assertEquals(21, pauses.count { it == 100L })
assertFalse(diagnostics.any { it.contains("选择规格") })
assertFalse(diagnostics.single().contains("选择规格"))
}
@Test
@@ -1464,7 +1218,6 @@ class PurchaseRehearsalExecutorTest {
private val reviewBackSucceeds: Boolean = true,
private val hiddenSizeUntilUpSwipes: Int = 0,
private val prefixlessSingleHeadingAfterColor: Boolean = false,
private val singleHeadingOnEntry: Boolean = false,
private val hideSizeAfterSelection: Boolean = false,
private val revealGridSizeAfterUpSwipes: Int? = null,
private val openClickResults: MutableList<FreshActionResult> = mutableListOf(),
@@ -1474,10 +1227,6 @@ class PurchaseRehearsalExecutorTest {
private val forcedEntryClickReason: FreshClickReason? = null,
private val initialSize: String? = null,
private val entryActionHasEffect: Boolean = true,
private val duplicateColorCardEvidence: Boolean = false,
private val dynamicProduct: Boolean = false,
private val productOverlay: Boolean = false,
private val afterEntryMutation: String = "",
private val specTapResult: FreshActionResult = FreshActionResult.FAILED,
private val specTapHasEffect: Boolean = true,
private val sizeSelectsOnFailedClick: Boolean = false,
@@ -1495,13 +1244,10 @@ class PurchaseRehearsalExecutorTest {
private val pullDownSucceeds: Boolean = true,
private val loseEvidenceAfterPull: Boolean = false,
private val unavailableSizes: Set<String> = emptySet(),
private val unavailableColors: Set<String> = emptySet(),
allSpecsUnavailable: Boolean = false,
private val nonScrollablePanel: Boolean = false,
private val confirmationPanelKind: String? = null,
private val unrecognizedPanel: Boolean = false,
private val purchaseSwipeSucceeds: Boolean = true,
private val purchaseSwipeFailure: PurchaseSwipeFailureReason = PurchaseSwipeFailureReason.UNKNOWN,
initiallyInAgent: Boolean = false,
private val panelBecomesUnknownAfterSizeProof: Boolean = false,
) : PurchaseUiDriver {
@@ -1532,7 +1278,6 @@ class PurchaseRehearsalExecutorTest {
private var horizontalColorPage = 0
private var horizontalSizePage = 0
private var capturesAfterSizeSelection = 0
private var entryAttempted = false
val clicked = mutableListOf<String>()
val clickedPaths = mutableListOf<String>()
@@ -1595,8 +1340,6 @@ class PurchaseRehearsalExecutorTest {
node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"),
)
val specEntryReady = pddCaptureCount > specEntryVisibleAfterPddCaptures
if (dynamicProduct) nodes += node("decoration", "", 20, 400 + pddCaptureCount % 3, 100, 460)
if (productOverlay) nodes += node("overlay-close", "关闭", 900, 500, 1000, 570, clickable = true)
if (bottomPurchaseEntry && specEntryReady) {
nodes += node("buy", "", 500, 1800, 1080, 2180, clickable = true)
nodes += node("buy/price", "¥20.00", 560, 1840, 760, 1910, parentPath = "buy")
@@ -1605,14 +1348,6 @@ class PurchaseRehearsalExecutorTest {
nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true)
}
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
if (entryAttempted) {
if (afterEntryMutation == "missing") nodes.removeAll { it.path == "spec" }
if (afterEntryMutation == "duplicate") nodes += node("spec2", "选择规格", 20, 1120, 900, 1190, clickable = true)
if (afterEntryMutation == "payment-promotion") nodes += node("payment", "微信支付", 20, 1800, 900, 1900)
if (afterEntryMutation == "payment") nodes += node("payment", "确认支付", 20, 1800, 900, 1900)
if (afterEntryMutation == "order") nodes += node("order", "订单详情", 20, 1800, 900, 1900)
if (afterEntryMutation == "activity") return UiSnapshot(PDD, "OtherActivity", nodes)
}
return UiSnapshot(PDD, ACTIVITY, nodes)
}
if (unrecognizedPanel) {
@@ -1621,7 +1356,7 @@ class PurchaseRehearsalExecutorTest {
node("panel-title", "确认款式", 20, 396, 300, 430),
))
}
val singleHeading = singleHeadingOnEntry || (prefixlessSingleHeadingAfterColor && color != null)
val singleHeading = prefixlessSingleHeadingAfterColor && color != null
val hideColor = singleHeading || (hideColorAfterQuantitySet && quantity == 2L && !hiddenColorRestored)
val hideSize = (hideSizeAfterQuantitySet && quantity == 2L) || (hideSizeAfterSelection && size != null)
val hideSummary = hideSelectedSummaryAfterQuantitySet && quantity == 2L
@@ -1655,16 +1390,6 @@ class PurchaseRehearsalExecutorTest {
nodes += node(colorParent, "", 0, 460, 1080, 550, scrollable = true, parentPath = "scroll")
}
visibleColors.forEachIndexed { index, value ->
if (duplicateColorCardEvidence && selectedColor == value) {
val cardPath = "scroll/card-$index"
nodes += node(cardPath, "$value 199", 20 + index * 220, 460, 200 + index * 220, 550,
clickable = true, selected = true, parentPath = "scroll", className = "android.view.ViewGroup")
nodes += node("$cardPath/image", "$value 199", 20 + index * 220, 460, 200 + index * 220, 500,
clickable = true, selected = true, parentPath = cardPath, className = "android.widget.ImageView")
nodes += node("$cardPath/text", value, 20 + index * 220, 500, 200 + index * 220, 540,
clickable = true, selected = true, parentPath = cardPath, className = "android.widget.TextView")
return@forEachIndexed
}
nodes += node(
"scroll/color-$index",
value,
@@ -1674,7 +1399,7 @@ class PurchaseRehearsalExecutorTest {
540,
clickable = true,
selected = selectedColor == value,
enabled = !allSpecsUnavailable && value !in unavailableColors,
enabled = !allSpecsUnavailable,
parentPath = colorParent,
)
}
@@ -1712,16 +1437,7 @@ class PurchaseRehearsalExecutorTest {
nodes += if (singleHeading) node("info/quantity", quantity.toString(), 400, 360, 600, 390, className = "android.widget.EditText", parentPath = "info")
else node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText")
if (!singleHeading) nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true)
if (confirmationPanelKind != null) {
nodes += node("close", "关闭", 980, 300, 1060, 350, clickable = true)
nodes += node("minus", "减少数量", 300, 800, 380, 870, clickable = true)
nodes += node("plus", "增加数量", 620, 800, 700, 870, clickable = true)
nodes += node("payment", "微信支付", 600, 1000, 900, 1050)
if (confirmationPanelKind == "QUICK_CONFIRMATION") {
nodes += node("quick", "现在买", 520, 2000, 1020, 2080, clickable = true)
}
}
nodes += node("order", "提交订单", 20, if (singleHeading || confirmationPanelKind != null) 2000 else 1100, 500, if (singleHeading || confirmationPanelKind != null) 2080 else 1180, clickable = true)
nodes += node("order", "提交订单", 20, if (singleHeading) 2000 else 1100, 500, if (singleHeading) 2080 else 1180, clickable = true)
nodes += node("pay", "立即支付", 520, 1100, 1020, 1180, clickable = true)
return UiSnapshot(PDD, ACTIVITY, nodes)
}
@@ -1762,7 +1478,6 @@ class PurchaseRehearsalExecutorTest {
}
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
if (target.path in setOf("spec", "buy")) entryAttempted = true
if (target.path in setOf("spec", "buy") && forcedEntryClickReason != null) {
val result = when (forcedEntryClickReason) {
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> FreshActionResult.NOT_FOUND
@@ -1816,8 +1531,6 @@ class PurchaseRehearsalExecutorTest {
return purchaseSwipeSucceeds
}
override fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = purchaseSwipeFailure
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
swipeInPaths += target.path
if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {
@@ -1,47 +0,0 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import org.junit.Assert.*
import org.junit.Test
class SelectedColorCardTest {
private val target = "黑色两件套 19"
private fun node(path: String, parent: String?, label: String, kind: String, selected: Boolean = true,
scrollable: Boolean = false) = SnapshotNode(path, parent, label, null, null, kind,
NodeBounds(0, 0, 300, 400), !scrollable, scrollable, selected, false, true, true)
private val scroll = node("s", null, "", "androidx.recyclerview.widget.RecyclerView", false, true)
private val card = node("s/c", "s", "黑色两件套 199", "android.view.ViewGroup")
private val image = node("s/c/i", "s/c", "黑色两件套 199", "android.widget.ImageView")
private val text = node("s/c/t", "s/c", target, "android.widget.TextView")
private fun check(nodes: List<SnapshotNode>, candidates: List<SnapshotNode> = nodes.filter { it != scroll }) =
SelectedColorCard.confirms(target, candidates.map { VisibleSpecValue(it.label, true, it) }, nodes)
@Test fun `selected target and own card image duplicates are one selection`() {
assertTrue(check(listOf(scroll, card, image, text)))
}
@Test fun `another selected card remains conflict`() {
val other = card.copy(path = "s/other")
assertFalse(check(listOf(scroll, card, image, text, other)))
}
@Test fun `two exact targets are not merged`() {
val otherCard = card.copy(path = "s/other", selected = false)
val otherText = text.copy(path = "s/other/t", parentPath = otherCard.path)
assertFalse(check(listOf(scroll, card, image, text, otherCard, otherText)))
assertFalse(check(listOf(scroll, card, image, text, otherCard, otherText), listOf(card, image, text)))
}
@Test fun `unselected target cannot borrow selection from its container`() {
assertFalse(check(listOf(scroll, card, image, text.copy(selected = false))))
}
@Test fun `scrollable or missing card boundary cannot merge`() {
assertFalse(check(listOf(scroll, card.copy(scrollable = true), image, text)))
assertFalse(check(listOf(card, image, text)))
assertFalse(check(listOf(scroll, card.copy(clickable = false), image, text)))
}
@Test fun `different text sibling is not treated as image duplicate`() {
assertFalse(check(listOf(scroll, card, text, image.copy(className = "android.widget.TextView"))))
}
@Test fun `out of card bounds and parent cycle are rejected`() {
assertFalse(check(listOf(scroll, card, image.copy(bounds = NodeBounds(0, 0, 301, 400)), text)))
assertFalse(check(listOf(scroll, card.copy(parentPath = text.path), image, text)))
}
}
@@ -1,92 +0,0 @@
package cn.ilapage.goauto.agent.persistence
import java.io.File
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class PurchaseDiagnosticStoreTest {
@get:Rule val temporary = TemporaryFolder()
private val context = PurchaseDiagnosticContext(81, "attempt-14", 7, "a".repeat(64), "0.9.67", "purchase")
@Test fun `only known structural fields and fixed values survive`() {
val output = PurchaseDiagnosticStore.sanitize(
"entryClick=SUCCESS;entryRecovery=rejected;reason=title_changed;summary=false;options=20;" +
"title=敏感标题;address=测试地址;token=secret;entrySource=private-value;gesture=private-value;" +
"quantity=private-value;scrollables=-1;unknown=private-value",
)
assertEquals(5, output.length())
assertEquals("title_changed", output.getString("reason"))
assertFalse(output.getBoolean("summary"))
assertFalse(output.toString().contains("private"))
assertFalse(output.toString().contains("secret"))
assertEquals(0, PurchaseDiagnosticStore.sanitize("reason=测试地址;entryReason=secret").length())
}
@Test fun `events persist across store recreation with identity and elapsed time`() {
val dir = temporary.newFolder()
assertTrue(PurchaseDiagnosticStore(dir) { 1000 }.record(context, 2300,
"entryRecovery=rejected;reason=close_control;entryWaitPolls=30;entryWaitMillis=2900;closeControl=true"))
assertTrue(PurchaseDiagnosticStore(dir) { 2000 }.record(context, 2500, "entryClick=SUCCESS"))
val lines = dir.listFiles()!!.single().readLines()
assertEquals(2, lines.size)
val entry = JSONObject(lines.first())
assertEquals(81, entry.getLong("taskId"))
assertEquals("attempt-14", entry.getString("attemptId"))
assertEquals(7, entry.getLong("deviceId"))
assertEquals(context.ruleHash, entry.getString("ruleHash"))
assertEquals(2300, entry.getLong("elapsedMs"))
assertEquals("0.9.67", entry.getString("agentVersion"))
assertEquals("close_control", entry.getJSONObject("evidence").getString("reason"))
}
@Test fun `saved confirmation diagnostics retain only bounded scalars`() {
val fields = PurchaseDiagnosticStore.sanitize("savedAddressMatched=false;savedPanelMatched=true;" +
"savedLegacySubmitCandidates=0;savedAddressCards=1;savedAddressTargets=1;" +
"savedScrollCandidates=0;savedConfirmationPolls=50;savedAddress=secret;" +
"savedAddressMatched=secret;savedScrollCandidates=-1")
assertEquals(7, fields.length())
assertFalse(fields.getBoolean("savedAddressMatched"))
assertEquals(50, fields.getInt("savedConfirmationPolls"))
assertFalse(fields.toString().contains("secret"))
}
@Test fun `retains last five attempts and last 128 events including failure`() {
val dir = temporary.newFolder()
var time = 1000L
val store = PurchaseDiagnosticStore(dir) { time++ }
repeat(7) { store.record(context.copy(attemptId = "attempt-$it"), 0, "event=started") }
assertEquals(5, dir.listFiles()!!.size)
assertFalse(File(dir, "81_attempt-0.jsonl").exists())
repeat(150) { store.record(context, it.toLong(), "entryWaitPolls=$it") }
store.record(context, 999, "entryRecovery=rejected;reason=entry_label_changed")
val lines = File(dir, "81_attempt-14.jsonl").readLines()
assertEquals(128, lines.size)
assertEquals("entry_label_changed", JSONObject(lines.last()).getJSONObject("evidence").getString("reason"))
assertEquals(5, dir.listFiles()!!.size)
}
@Test fun `expiry deletes only owned files and can run at startup`() {
val dir = temporary.newFolder()
PurchaseDiagnosticStore(dir) { 1000 }.record(context, 0, "event=started")
File(dir, "unrelated.txt").writeText("preserve")
File(dir, "unrelated.txt").setLastModified(1000)
PurchaseDiagnosticStore(dir) { PurchaseDiagnosticStore.RETENTION_MS + 1001 }.prune()
assertEquals(listOf("unrelated.txt"), dir.listFiles()!!.map { it.name })
}
@Test fun `invalid context and filesystem failures never throw`() {
val store = PurchaseDiagnosticStore(temporary.newFile())
assertFalse(store.record(context, 1, "event=started"))
store.prune()
val dir = temporary.newFolder()
val validStore = PurchaseDiagnosticStore(dir)
assertFalse(validStore.record(context.copy(attemptId = "../outside"), 1, "event=started"))
assertFalse(validStore.record(context.copy(ruleHash = "secret"), 1, "event=started"))
assertFalse(validStore.record(context.copy(phase = "secret"), 1, "event=started"))
assertFalse(validStore.record(context, 1, "unknown=secret"))
assertTrue(dir.listFiles()!!.isEmpty())
}
}
@@ -1,39 +0,0 @@
package cn.ilapage.goauto.agent.service
import org.junit.Assert.*
import org.junit.Test
class PurchaseProbeHandoffTest {
private val key = PurchaseProbeHandoff.Key(107, "123456", "a".repeat(64), 7)
@Test fun `same task consumes once and manual retry cannot inherit`() {
val store = PurchaseProbeHandoff()
store.remember(key, "PddActivity", 3, 100)
assertEquals("PddActivity", store.consume(key, "purchase", 3, 60_100))
assertNull(store.consume(key, "purchase", 3, 60_101))
}
@Test fun `mismatched task goods rule device phase revision and expiry reject and consume`() {
for (other in listOf(key.copy(taskId = 108), key.copy(goodsId = "654321"),
key.copy(ruleHash = "b".repeat(64)), key.copy(deviceId = 8))) {
val store = PurchaseProbeHandoff()
store.remember(key, "PddActivity", 3, 100)
assertNull(store.consume(other, "purchase", 3, 101))
assertNull(store.consume(key, "purchase", 3, 102))
}
for ((phase, revision, now) in listOf(Triple("spec_probe", 3L, 101L),
Triple("purchase", 4L, 101L), Triple("purchase", 3L, 180_101L), Triple("purchase", 3L, 99L))) {
val store = PurchaseProbeHandoff()
store.remember(key, "PddActivity", 3, 100)
assertNull(store.consume(key, phase, revision, now))
}
}
@Test fun `clear and process recreation discard context`() {
val store = PurchaseProbeHandoff()
store.remember(key, "PddActivity", 3, 100)
store.clear()
assertNull(store.consume(key, "purchase", 3, 101))
assertNull(PurchaseProbeHandoff().consume(key, "purchase", 3, 101))
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 029dc3e155e1828484c62efd0ac804616fcbc714
synchronized_at: 2026-09-10T09:13:27Z
synchronized_at: 2026-09-11T09:09:07Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: d8aeff02fba57cba0b53fb22cdb524fd5cc0cb3e
synchronized_at: 2026-09-10T09:13:33Z
synchronized_at: 2026-09-11T09:09:16Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
synchronized_at: 2026-09-09T03:16:51Z
synchronized_at: 2026-09-11T09:10:00Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
+198 -4
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: 49dec4c35da793a32822a82e00f3b657757b6d44
synchronized_at: 2026-09-07T13:35:25Z
wiki_revision: ba259178cf6514d5b48ea425005dfd366ba803a3
synchronized_at: 2026-09-12T03:09:46Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -77,7 +77,7 @@ PATCH /api/admin/v1/syb-products/{productId}/correction
`import` 允许已认证的管理员(admin)和采购员(purchaser)调用(#236),仍须通过 Casbin 权限校验;其他角色返回 403。采购员的 POST 权限由既有启动权限对账写入,不需要新增数据库迁移。操作人取已认证 claims,不接受客户端冒名。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `partial_success` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`),不含原始 JSON;`keyword` 匹配订单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。
@@ -100,6 +100,33 @@ POST /api/admin/v1/syb-shops/discover
`discover` 提交日期范围,从 SYB 真实货运单列表发现店铺,返回 `shopName` 和 `status`(`new` / `exists`);发现只读,不自动新增。店铺比较固定执行首尾去空白、全角/半角统一和忽略大小写。同步先完成原始列表完整性校验,再按一次性启用店铺快照过滤;明细响应中的店铺名为空或不在快照内时不得写入。
## 管理端:SYB 商品过滤(#269)
```http
GET /api/admin/v1/syb-product-filters
POST /api/admin/v1/syb-product-filters
PATCH /api/admin/v1/syb-product-filters/{id}/enabled
DELETE /api/admin/v1/syb-product-filters/{id}
```
同步时按 `variationSku` 决定明细是否入库。规则分两类,由 `kind` 区分:`char` 是结构判据
(`-`、`#`),`keyword` 是关键词清单。列表支持 `kind`、`keyword` 和分页,返回 `keyword`、
`normalizedKeyword`、`enabled`、`lastHitCount`、`lastHitAt`、`note`,`char` 另有
`lastChangedBy`、`lastChangedAt`。
`[必须]` 服务端必须拒绝对 `kind=char` 行的 `POST` 新增与 `DELETE` 删除,分别返回
`FORBIDDEN`(`结构过滤不可新增` / `结构过滤不可删除`),不得只依赖前端隐藏按钮。
`kind=char` 的 `PATCH enabled` 允许,并记录操作人与时间。
`[必须]` `lastHitCount` 是每条规则各自的命中数,由同步结束时回写;未命中的规则写 0,
从未同步过为 `null`。停用 `kind=char` 的二次确认提示条数必须取该行实时的 `lastHitCount`,
不得写死——写死的数字在数据变化后会给出错误警告。
关键词归一化与店铺一致:去首尾空白、全角/半角统一、忽略大小写;**不做简繁转换**,SYB 数据
为繁体。唯一性按 (`kind`, `normalizedKeyword`) 判定,重复添加返回
`SYB_PRODUCT_FILTER_DUPLICATE`。写操作由 go-admin/Casbin 仅授权管理员,其他角色只读。
删除为软删除,不清理已经入库的商品明细。
## 管理端:采集规则
```http
@@ -556,7 +583,17 @@ Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attem
创建订单前先在本地事务保存 `order_submit_started`、不可逆时间、稳定的 `orderSubmitRequestId` 和脱敏最终确认快照,再调用服务端同名接口;只有两侧标记完成并重新校验商品、规格、数量、单价、地址后缀、PDD 包/Activity 与唯一创建订单按钮后,才点击一次。重启时重放同一标记请求并只读核单;无法取得唯一未付款订单号和 PDD 下单时间时提交 `order_result_unknown`。支付文字仅用于识别未付款/离开支付页,永不点击。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。
创建订单后若出现 Android 多微信应用选择器,只读核单器必须同时确认前台包为 `android` / `com.android.intentresolver`、Activity 为白名单 `ChooserActivity` / `ResolverActivity`、页面出现已知系统选择器标题且至少一个候选以“微信”开头,才允许执行一次系统返回;不得点击任何微信候选。若前台已经是精确微信包 `com.tencent.mm`,只读核单器不得点击、输入、登录、支付或强制停止微信,只允许执行一次无参数 PDD 启动 Intent 并等待既有 PDD 任务栈回到前台;`startActivity()` 成功只表示恢复请求已发起。请求后在固定最多 15 次、每次 200ms 的宽限期内允许微信或空窗口短暂残留,不执行点击、返回、输入或滑动;观察到 PDD 后结束宽限,宽限超时、已经观察到 PDD 后再次进入微信或出现稳定未知应用时返回未知结果。随后若前台为 PDD `com.xunmeng.pinduoduo.app_pay.core.PayActivity` 或当前页面出现支付动作文字,最多再返回一次。返回后只读解析唯一订单号和下单时间;选择器或支付页重复出现、白名单不成立、恢复动作重复、无法到达订单详情、结果不唯一或超时均返回未知结果,禁止再次点击创建订单、取消订单或支付。
`order_result_unknown` 保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单并按错误码写入固定提示,不信任或保存页面原文;阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单错误码,并按错误码写入固定提示作为面向人的主文案;不采信 Agent 文本作为权威描述,也不保存原始控件树或截图。阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
#271 修订:`errorMessage` 的语义由「仅固定提示」改为「**固定提示 + Agent 诊断证据**」。固定提示仍由错误码唯一决定并排在最前,Agent 提交的脱敏诊断追加在其后。
`[必须]` 追加前压平为单行:去除控制字符与零宽不换行空格、连续空白合并为一个空格;按 `error_message` 列宽 1000 截断,且**固定提示优先完整保留**,被截断的证据以 `…` 结尾。证据与固定提示相同时不重复拼接。Agent 文本为空或错误码不在白名单时行为不变,仍返回 `INVALID_REQUEST`。
改动原因:原做法丢弃了 Agent 已经算出并上报的诊断。`#210` 为 `PURCHASE_ORDER_PAYMENT_REPEATED` 计算 `paymentBackAttempts` 与 `consecutivePaymentSamplesAfterBack`,但 2026-09-07 至 09-11 线上 61 次该错误的 `error_message` 无一携带这两个值,导致「返回动作未生效」「页面渲染慢于容忍窗口」「页面已是订单页但关键词未命中」三种情形无法区分。相邻的 `failed` 结果分支本就原样保存 Agent 文本,因此本修订不引入新的信任假设。
`[必须]` `failed` 结果分支同样把错误码与消息写入 attempt,不再只写任务。此前该路径产生的 attempt 行 `error_code` 为 NULL,按 attempt 统计失败时这些记录是隐形的。
#241 追加:全局订单号唯一性校验在人工处理结果未知、取消及 lifecycle 保存路径返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`(HTTP 409、`retryable=false`),提示“订单号已属于任务 CG-任务ID”;批量回填继续使用原有 `PURCHASE_BACKFILL_ORDER_ALREADY_USED`。不可逆边界后的 `order_created` 结果回传为例外:发现该号已属于其他任务时成功受理结果,将 `order_submit_started` 降级为 `order_result_unknown`,不回滚或自动重派。冲突订单号不写入 `pdd_order_no`,而以“读到订单号 X,但该号已属于任务 CG-yy”保存到任务和 attempt 的 `error_message`,两者 `error_code` 均为 `PURCHASE_ORDER_NUMBER_ALREADY_USED`;保留下单时间、不可逆时间及实际单价。attempt 以 failed 结束并保留原始 `order_created` 结果类型、请求 ID 和摘要,重复提交按原幂等协议返回;任务释放租约和运行槽,进入既有人工处理结果未知通道,权限不变。
| 错误码 | 普通提示 |
|---|---|
@@ -975,3 +1012,160 @@ Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口
本地 `GoAutoPurchasePanel` 脱敏结构日志关联 task、attempt、device、rule(规则 SHA-256),不上传原始页面。新增事件:`postSwipe=skipped;action=openSpecPanel;reason=spec_panel_on_demand;panel=<枚举>`;其他必需滑动失败为 `postSwipe=failed;action=<动作枚举>;direction=<方向枚举>;swipeIndex=<本动作内第几次滑动>;reason=<固定分类>`。
固定失败分类:unknown、root_unavailable、no_scrollable、invalid_bounds、gesture_unsupported、gesture_rejected、gesture_cancelled、gesture_timeout。日志不含规格值、节点文本、坐标、地址、订单、凭据、原始树或截图;结果错误码仍为 RULE_ACTION_FAILED,现有结果提交字段不变。
## SYB 逐页保存与部分成功(#239)
实现绑定 c6a962d;代码已实现不代表当前线上已部署。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
## Agent 采购订单批量回填(#241)
本节为 #241 服务端实现契约,2026-09-08 按用户授权直接更新本地镜像;线上 Wiki 与其他长期文档由审核阶段同步。本节不表示已经部署或完成真机验收。
`POST /api/agent/v1/purchase-tasks/order-backfill`
使用 `Authorization: Bearer <Device Token>`,沿用 `RequireAgentHTTPS`、`GOAUTO_ALLOW_INSECURE_AGENT_HTTP` 与既有可信转发协议策略。无需 Admin JWT、claim、start 或 attempt。设备号只从认证读取,请求不得指定 deviceId、地址全文、收件人、手机号或原始控件树;未知 JSON 字段拒绝。此接口只记录已观察到的订单事实,不执行设备动作、创建订单或付款。
请求示例(页面时间先按 Asia/Shanghai 理解,再以带时区 RFC3339/RFC3339Nano 发送):
```json
{
"requestId": "5826cdda-dcd6-442e-90c3-9b75ba6fb8d8",
"items": [
{"addressSuffix": "_cg7", "pddOrderNo": "EXAMPLE-ORDER-7", "orderSubmittedAt": "2026-09-08T20:30:00+08:00"},
{"addressSuffix": "_cg72", "pddOrderNo": "EXAMPLE-ORDER-72"}
]
}
```
- requestId 必须为 UUID;items 为 1~50 条,保持输入顺序;请求体上限沿用 1 MiB。
- addressSuffix 只接受 `AddressSuffix(id)` 生成的完整字符串。任务号为非零 uint64,拒绝前导零、正负号、空格、尾随文本、多个后缀与溢出;`_cg7` 与 `_cg72` 分别定位任务 7 和 72。
- pddOrderNo 必填,最多 100 个 Unicode 字符,不接受首尾空白、换行或制表符,不自动裁剪后覆盖旧值。
- orderSubmittedAt 缺失或 null 时回落任务 irreversible_at;空字符串、无时区文本及非法时间是条目错误,不触发回落。存储统一 UTC。页面值与 irreversible_at 都缺失时该条失败。
有效批次返回 HTTP 200,包括全部条目失败的批次;每条独立事务,失败不撤销其他条目已提交的数据。响应包裹为 `data`,并设 `Cache-Control: no-store`:
```json
{
"data": {
"requestId": "5826cdda-dcd6-442e-90c3-9b75ba6fb8d8",
"items": [
{"index": 0, "taskId": 7, "result": "backfilled", "code": "BACKFILLED", "status": "order_created", "statusVersion": 5, "pddOrderNo": "EXAMPLE-ORDER-7", "orderSubmittedAt": "2026-09-08T12:30:00Z", "timeSource": "page", "retryable": false},
{"index": 1, "taskId": 72, "result": "backfilled", "code": "BACKFILLED", "status": "order_created", "statusVersion": 4, "pddOrderNo": "EXAMPLE-ORDER-72", "orderSubmittedAt": "2026-09-08T12:31:00Z", "timeSource": "irreversible_at", "retryable": false}
]
}
}
```
index 从 0 开始;后缀无法解析时不返回 taskId。result 为 `backfilled`、`already_backfilled`、`conflict` 或 `failed`。已认证设备所属任务可返回提交后的状态、版本、已保存订单号及时间;拒绝条目尽可能返回当前已提交事实。跨设备任务和不存在任务不返回这些业务字段,事务回滚后的内存值绝不作为最终事实返回。
timeSource 说明已保存时间的来源:`page` 为页面值,`irreversible_at` 为估算回落,`existing_unknown` 为原先已创建的历史订单且没有可证明的来源。没有已保存时间时省略 timeSource。客户端必须保留估算标记,不得把回落值或 unknown 宣称为页面真实时间。重复回填不会用新页面时间自动校正旧时间。
| 条目 code | result | 含义 |
|---|---|---|
| `BACKFILLED` | backfilled | 本条完成回填 |
| `ALREADY_BACKFILLED` | already_backfilled | 正式任务已为 order_created 且订单号相同,无写入 |
| `PURCHASE_BACKFILL_SUFFIX_INVALID` | failed | 非法、非规范、零、溢出或歧义后缀 |
| `PURCHASE_TASK_NOT_FOUND` | failed | 任务不存在 |
| `PURCHASE_BACKFILL_DEVICE_MISMATCH` | failed | 未绑定设备或不属于认证设备 |
| `PURCHASE_STATE_CONFLICT` | failed | 非正式采购,或状态不允许回填 |
| `PURCHASE_INVALID_REQUEST` | failed | 订单号非法 |
| `PURCHASE_ORDER_TIME_INVALID` | failed | 提供的页面时间无效 |
| `PURCHASE_ORDER_TIME_MISSING` | failed | 页面时间与 irreversible_at 均无有效值 |
| `PURCHASE_BACKFILL_ORDER_CONFLICT` | conflict | 任务已有不同订单号 |
| `PURCHASE_BACKFILL_BATCH_CONFLICT` | conflict | 同批同任务出现多个不同订单号,该任务所有条目均拒绝 |
| `PURCHASE_BACKFILL_ORDER_ALREADY_USED` | conflict | 同一订单号已对应其他任务 |
| `INTERNAL_ERROR` | failed | 数据库失败、死锁等,retryable=true,可安全重放 |
批级 JSON/UUID/数量错误为 HTTP 422 `PURCHASE_INVALID_REQUEST`;鉴权、停用设备和 HTTPS 限制复用既有错误(401 `DEVICE_TOKEN_INVALID`、403 `DEVICE_DISABLED`、426 `HTTPS_REQUIRED`)。批级失败使用既有 `{code,message,retryable}` 包裹,未开始条目写入。
### 状态、幂等与并发
新服务在事务中锁定任务并检查来源状态,仅允许当前设备的 `live + order_result_unknown` 首次写入;`live + order_created` 只在订单号相同时返回已回填。其他状态(包括 running、failed、cancelled 与演练)均拒绝。复用 SetStatus 同步占用字段,同一事务递增 statusVersion、设置 statusChangedAt、清空主任务当前错误及租约;原始 attempt、规则快照、支付与物流、SYB 回写字段不变。
requestId 沿用 UUID 约定,不增加批次表或全局幂等缓存。既有 unknown_resolve_request_id 槽存储 `backfill:<page|irreversible_at>:<由 requestId 和后缀派生的 UUID>`(最多 61 字符),用于任务级关联和保留本功能时间来源。相同任务和订单号即使更换 requestId 也无写入;同 requestId 改内容仍重新执行设备、状态和订单冲突检查,不凭 requestId 直接放行。批内不同任务提交同一订单号时,先成功提交者占用,其余条目返回订单已被使用;已存在的历史重复订单号不自动修复。
订单号尚无唯一索引,本实现不迁移数据库。共享模型保存钩子复用 `purchase_rule_setting.id=1` 行作短事务互斥锁,再以锁定读检查订单号归属,覆盖回填、原人工解除和旧结果提交路径;单例缺失时拒绝写入,数据库死锁时回滚失败事务。原 ResolveUnknown 与 Admin 鉴权代码保持不变。禁止通过跳过模型钩子的直接 SQL 写入宣称具备此保证。
新回填路径禁用包含绑定参数的 SQL 日志,不记录请求正文、订单号、地址或原始树;任务号和认证设备号沿既有任务关联不可变 ruleSnapshot。此接口未新增日志载荷或任务/attempt。
本地测试覆盖事务回滚、并发服务调用和旧写入路径,使用 SQLite;MySQL 8.4 多连接/多进程的实际行锁、生产数据和真机端到端回填尚待环境验收,不以单元测试替代。
## 蝦皮详情匹配等待与回读(#254)
实现绑定 `9088e6b`(2026-09-10,分支 fix/254-match-loading);已通过合成数据测试,尚未合并 main 或发布线上。
本节是 Admin 蝦皮详情接口补充,不修改 Android 请求或采购协议。
- `GET /api/admin/v1/shopee-products/:productId` 的 `data` 增加 `autoMatchTimeoutSeconds` 整数,为后端详情一键匹配整次预算 `2 × AI timeoutSeconds + 10`,配置范围3~600对应16~1210秒。它是非敏感派生预算,沿用商品详情读取权限;不是开放 AI 管理配置读取,响应不包含 Key、Provider、BaseURL 或 Model。现有客户端路由若复用本 Handler 同样仅获得这个数字。
- `POST /api/admin/v1/shopee-products/:productId/specs/mapping/auto-match` 请求字段、业务权限、版本核对与响应映射语义不变。服务端按收到请求时的 AI 配置确定整次父预算,读取配置耗时扣除;Provider 请求仍受自身超时及父 context 剩余时间中更短者约束。
- Admin 点击前先GET新详情取得预算,POST timeout=(autoMatchTimeoutSeconds+10)×1000毫秒;缺字段、非整数或超范围时明确拒绝开始匹配,不猜配置。前后端需配套升级。
- 准备后配置可能变化,浏览器仍可能先超时;不要据超时响应断言事务是否提交。POST失败/断连/超时只执行有限GET回读,不自动重发POST;失败回读显示结果未知。回读结果表示当前已保存状态,不保证这次请求已经永久不再写入,也不保证全部项目都匹配成功。
- 普通GET默认10秒;本轮未修改建议接口610秒、其他AI功能或后台批量匹配600秒预算。T=60时130/140秒、T=180时370/380秒。
## SYB 行内一键关联最近采集(#253)
实现绑定 `b1594dc`,2026-09-10;分支 feat/253-one-click-link,包含 #254 依赖。已完成合成数据测试及构建,未合并 main、未部署,不代表当前线上已具备此能力。
本节仅补充 Admin API;Android 和客户端密钥旧请求不变。
`POST /api/admin/v1/shopee-products/{productId}/link-pdd` 新增与非零 pddProductId 互斥的可选模式:
```json
{"requestId":"UUID","latestCollection":{"sybProductId":1,"deviceId":2,"specContextVersion":"详情返回的未关联版本"}}
```
- 校验当前 SYB→Shopee、未关联、规格版本和设备存在/未停用;离线可用。短事务选择最新已结束成功或部分成功的临时采集,排序 finished_at DESC、id DESC;部分成功不跳过回退旧记录。只接受 completed、存在 finishedAt/PDD、PDD active 且存在可选颜色或尺码。
- 成功返回原 `product`,及 `collectionSource: {taskId, deviceId, pddProductId, requiresSpecConfirmation}`。product.specContextVersion 是此次事务写入后的冻结版本;requiresSpecConfirmation 对应 failed 且没有人工或AI确认,完整资格仍由现有 batch-preview 派生。不得把后续其他人的商品版本当作本次自动匹配上下文。
- 409 `PDD_LINK_CONFLICT` 表示已有关联;409 `LATEST_COLLECTION_UNAVAILABLE` 表示设备/最近采集/候选不可用;409 `SPEC_CONTEXT_VERSION_STALE` 沿用现有语义。非法参数422。重复请求不会覆盖已有关联,成功响应丢失后重发也会冲突,客户端应回读而非自动重试。last_update_request_id 保留本次请求标识;不承诺额外持久化完整重放响应。
- `/api/client/v1/.../link-pdd` 带 latestCollection 时返回403 FORBIDDEN,不扩大API Key授权模块;原手动 pddProductId 模式行为不变。
- Web 先GET详情及 #254 autoMatchTimeoutSeconds,再提交关联;关联响应中的冻结版本交给现有 auto-match。其匹配仍自动保存,无额外“保存修改”写请求。普通请求/回读每次10秒,auto-match 单独使用派生前端预算2T+20秒;业务拒绝直接提示,网络/匹配失败至多一次详情回读,再刷新准备状态,禁止自动POST重试。
- Server/Web 需配套更新并包含 #254;无需迁移/Android升级。现有人工关联、匹配阈值、规格/采购资格、任务与支付边界不变。
## Admin 一键替换最近采集候选(#258)
实现绑定 `2b01974`;仅补充 Admin,Android/API Key 不变。沿用 `POST /api/admin/v1/shopee-products/{productId}/link-pdd` 和原权限,不新增路由或迁移。
先请求只读预览(仍需 UUID requestId):
```json
{"requestId":"UUID","latestCollection":{"sybProductId":1,"deviceId":2,"specContextVersion":"当前详情版本","replacement":{"preview":true,"expectedPddProductId":3}}}
```
响应 `product` 保持原关联,`collectionSource` 返回 taskId、deviceId、pddProductId、requiresSpecConfirmation,并增加 targetVersion、title、goodsId、previousTitle、previousGoodsId、deviceName。预览没有关联写入。
确认提交沿用同一 SYB/设备/详情版本与原商品ID,replacement 改为:
```json
{"preview":false,"expectedPddProductId":3,"taskId":4,"targetPddProductId":5,"targetVersion":"预览返回值"}
```
- 提交按指定 taskId 核对临时采集来源、设备、completed/finishedAt、目标ID、可用规格及 targetVersion;不重新选择最新任务。旧关联、旧/目标规格版本变更返回现有409上下文冲突;阶段不再允许返回409 PDD_LINK_CONFLICT;来源不可用409 LATEST_COLLECTION_UNAVAILABLE;缺字段422。
- 不同商品条件更新旧关联,返回更新后的 product.specContextVersion;同商品不更新关联及 last_update_request_id,但仍返回用于后续匹配的当前版本。请求不是持久化重放协议;客户端超时只回读、不自动重发。
- 预览与提交都校验当前允许阶段;采集中拒绝。复用现有人工更换关联的映射语义,后续 auto-match 自动保存及预算沿用 #254。部分匹配/AI故障不回滚已提交关联。
- 不提供 replacement 时 #253 行为不变;`/api/client/v1` 携带 latestCollection(包括 replacement)仍403,不扩大客户端密钥授权。
## 辅助信息缺失的采集关联(#259)
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
## 采购规格现场一次重新匹配(#263)
2026-09-11 实现绑定 `c6ee6c0`,Server采购与Android Agent;未发布、未安装、未完成真实采购验收。
- 正式采购阶段仅在服务端下发 `specRematchAllowed=true`、Android 返回稳定的 `PURCHASE_SPEC_TARGET_NOT_VISIBLE` 且尚未进入地址/下单阶段时触发;颜色/尺码选中复核失败、售罄、点击失败、歧义、价格、地址和订单错误不触发。
- Agent 在当前PDD规格面板重新读取候选并提交同一采购任务的 `spec_rematch_completed` 结果;Server复用现有确定性优先/必要时AI匹配,结果必须属于当次候选。匹配成功后同task重新领取正式attempt,Agent沿用当前页面继续正式规格、数量、价格、地址和下单校验,不新建任务、不重新打开浏览器链接;页面证据失效时仍按原安全路径处理。
- 每个采购任务最多一次;结果attempt和服务端状态原子记录,旧决策指针清除后只允许最新attempt固化新决策。重复请求、无资格、已有不可逆证据或旧attempt覆盖均拒绝;不新增数据库列,依赖现有attempt result_type审计。
- 新共享字段 `specRematchAllowed` 和结果类型仅供现有Agent采购接口;旧Agent缺省false,旧 `spec_probe_completed` 重复探测拒绝规则不变。未改变设备租约、并发、支付禁止和任务重试语义。
+42 -20
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: a4f4ab200af052bab7ffbabe267238528bcd7cf9
synchronized_at: 2026-09-07T07:02:57Z
wiki_revision: f1792b7fe35925480c25b890e85fa5e551744812
synchronized_at: 2026-09-11T09:11:21Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
@@ -220,22 +220,13 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整
有限重试:单次 HTTP 总超时 60 秒,最多执行 3 次,重试前分别等待 1 秒、2 秒,等待须
响应 context 取消。HTTP 401/403、明确会话失效、业务失败、接口数据完整性错误和本地
校验失败不得重试;登录、验证码、单件码写入和任何回填请求也不得使用该机制。重试
耗尽后返回最后一次错误,外层继续保留日期、页码和已获取数量上下文并将同步标为失败。
耗尽后返回最后一次错误,外层保留日期、页码和已获取数量上下文,按最终已保存成果将同步标为失败或部分成功。
今天的货运单会在同步期间持续新增。只有 UTC+8 下的今天发生上述快照漂移时,
允许只重试今天的列表分页,最多 3 次;已经完成的历史日期不得重复拉取,每次尝试
也必须使用独立 ID 集合。第三次仍不稳定时,可以对最后一次取得的合法唯一 ID
读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成
稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`;
网络/业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。
`[必须,#235]` 当天跨页重复 ID 纳入上述最多 3 次列表快照尝试(含首次),
不增加另一层重试次数。发现跨页重复后丢弃本次列表,从预检总数和第一页重新开始,
使用全新 ID 集合;最后一次仍重复时直接失败,不得去重后按成功或降级数据保存。
已经完成的历史日期保持其已有结果,不重复拉取;历史日期重复不适用此恢复。
每页先检查非法 ID 和页内重复,再检查跨页重叠;同页同时存在页内重复和跨页重叠时
仍作为硬错误停止。原有总数漂移/短页的合法唯一列表降级保存条件保持不变。
重复诊断只记录日期、当天尝试序号、首次/当前页码与行号、start、pageSize、
expectedTotal 和已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
自 #239(实现提交 c6a962d,已于 2026-09-08 随 d403f3b 部署线上)起,改为按页验证和保存:每页先验证条数、合法 ID、页内/跨页重复,再按冻结店铺快照获取并验证全部明细;外部请求完成后才开启页事务。页内任一入库失败回滚整页,已提交的前页保留,计数在事务提交后累计。
当天和历史日期统一处理:分页/明细失败或最终总数漂移时停止该日期并继续后续日期,不在同次运行中重新扫描当天,以避免重复写入和计数。数据库故障、进度持久化失败、明确会话失效、上下文取消或总任务超时停止整个范围。下一次人工或定时同步重新扫描日期,按 (order_code, detail_id) 幂等覆盖并保留人工确认。跨页重复不去重后冒充完整数据;最终日期完整性校验仍覆盖所有店铺。此流程替代此前 #235 的当天三次整日快照重扫。
重复诊断只记录日期、首次/当前页码和行号、start、pageSize、expectedTotal、已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
### 4.4 统一日期范围同步与覆盖游标
@@ -258,7 +249,7 @@ GoAuto 同步记录页的立即同步允许管理员和采购员(purchaser)
或超过 `max_matches` 时不推进游标。
`[必须]` 每批明细响应必须与请求的货运单 ID 一一对应。缺失、重复、出现未请求
ID,或某张货运单返回空商品明细,都视为不完整并停止同步;已经写入的幂等数据
ID,或某张货运单返回空商品明细,都视为不完整并停止当前日期,继续后续日期;已经写入的幂等数据
可以保留,但只有所有日期全部成功才推进游标。
`[必须]` 登录和验证码只是同步前置步骤。日期范围经过自动 OCR 降级、手工
@@ -500,9 +491,30 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
> 以下「店铺准入」已由 GoAuto #49 采纳并实现;店铺规范化还包括全角/半角统一和忽略大小写。
`[必须]` **同步先校验原始全量,再做店铺准入。** 顺序固定为:按日期查询原始总数
并执行单次容量熔断 → 拉完当天原始列表并核对分页前后总数、页长和唯一 ID → 按
并执行单次容量熔断 → 逐页核对页长和唯一 ID,整日结束复核总数 → 按
`shopName` 去除首尾空白后与启用店铺精确匹配 → 只为接受的货运单请求明细和入库。
不能先过滤再做完整性校验,否则非目标店铺的分页漂移会被掩盖。
店铺过滤只决定明细获取和入库,不能减少原始列表完整性校验范围;已保存不代表整日完整。
`[必须]` **店铺准入之后再按 `variationSku` 过滤商品(#269)。** 顺序固定为:店铺准入 →
结构过滤 → 关键词过滤 → 入库。两类规则都存在 `syb_product_filter`,由 `kind` 区分:
- `kind=char`:`-` 和 `#` 两条,命中任意一条即跳过(**OR,不是 AND**)。这两个字符是
「档口-供应商#货号」编码格式的判据。2026-09-11 核验线上 13010 行明细:含 `#` 8498 行、
含 `-` 6392 行、两者都含 6386 行、任一 8504 行(65%)。用 AND 只命中 6386 行,会漏掉
`DD#004`、`300斤牛奶絲圓領#A057` 这类只含 `#` 的 2118 行。
- `kind=keyword`:关键词清单,匹配 `variationSku`,**不匹配 `productTitle`**。同一次核验
中六条初始关键词在 `productTitle` 上命中为 0,且全部已被结构过滤覆盖,净增为 0;保留它
是为档口改用不含 `#` 的编码时兜底。
`[必须]` 空 `variationSku` **不过滤**。核验时 2724 行(21%)为空,分布在正常启用店铺且持续
产生,属于尚未填写供应商编码的正常商品,不是档口货。
`[必须]` 两类命中数分别统计并分别回写到每条规则自己的 `last_hit_count`,不得合并成单一
「过滤总数」,也不得按 `kind` 写同一个汇总值。前者会使结构判据失效不可观测(关键词净增为 0
时无法区分「没漏网」和「规则没生效」);后者会让停用确认对 `#` 和 `-` 提示同一个数字。
`[必须]` `kind=char` 不可新增、不可删除,只能停用,且停用需要二次确认并记录操作人与时间。
判据本身需要变更(例如档口改用 `/` 或 `@`)属于范围变化,应另建工单评估。
`[必须]` 同步开始时只读取一次启用店铺,整次运行使用同一个快照。列表允许但明细
响应中的 `shopName` 变为空或非允许店铺时再次拦截。没有启用店铺时在会话/OCR/
@@ -657,3 +669,13 @@ settings:
- 客户端实现:`server/app/goauto/sybclient/`
- 落库与解析:`server/app/goauto/sybimport/`
- 相关工单:[#48 客户端移植](https://git.ilapage.cn/OPC/goauto/issues/48)、[#41 SYB 货运单商品导入](https://git.ilapage.cn/OPC/goauto/issues/41)、[#37 物流调度与货运宝回填](https://git.ilapage.cn/OPC/goauto/issues/37)
## SYB 逐页保存与部分成功(#239)
实现绑定 c6a962d;2026-09-08 已随 d403f3b 部署线上,未手动触发真实同步验收;#240 上游尾页超时尚未修复。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
+17 -6
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: f951dbc8b6a555cbf8f44cda073910406ba55191
synchronized_at: 2026-09-07T09:43:56Z
wiki_revision: 933eaf6cc02fed843a6898269fdb5f6848ac2a8a
synchronized_at: 2026-09-10T01:42:05Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -41,7 +41,7 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true
### API 长请求超时约束
Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允许 3~600 秒,复合的一键匹配共享 600 秒总预算,Admin 客户端等待 610 秒。因此服务端 `settings.application.writetimeout` 必须至少为 620 秒,受版本管理的标准配置固定为 `620`;启动时低于该值必须明确失败,禁止以 2 秒等短写超时运行,否则连接会在领域错误返回前被截断并表现为代理 502。反向代理的读/发送超时也必须大于服务端窗口。
Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允许 3~600 秒,旧版详情复合一键匹配共享600秒总预算,旧Admin客户端等待610秒;#254未部署前仍适用,部署后详情入口改用下节动态预算。因此服务端 `settings.application.writetimeout` 必须至少为 620 秒,受版本管理的标准配置固定为 `620`;启动时低于该值必须明确失败,禁止以 2 秒等短写超时运行,否则连接会在领域错误返回前被截断并表现为代理 502。反向代理的读/发送超时也必须大于服务端窗口。
Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。
@@ -73,10 +73,10 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
## 客户端密钥部署与验证(#237)
实现基线 `71f7751`;2026-09-07 已按用户授权完成本机 MySQL 迁移、管理员菜单写入和 Server/Web 构建重启;管理页面 HTTP 列表与模块加载已验证。线上仍未部署。
实现基线 `71f7751`;2026-09-07 已按用户授权完成本机 MySQL 迁移、管理员菜单写入和 Server/Web 构建重启;管理页面 HTTP 列表与模块加载已验证。2026-09-08 经用户授权,线上已发布 Server/Web 基线 `d403f3b`,执行迁移 `1788798000000` 并重启 GoAuto/Nginx;迁移记录、两张密钥表、仅管理员菜单关联已回读。HTTP 页面与健康检查通过;未登录管理请求 HTTP 200、业务码 401。真实密钥创建及客户端业务读写闭环尚未验证。
- 发布前须单独授权追加迁移 `server/cmd/migrate/migration/version-local/1788798000000_client_api_key.go`,按既有迁移流程创建 client_api_key、client_api_key_audit 和“采采管理/客户端密钥”管理员菜单。前置父菜单必须存在;不应以赋予普通用户管理员角色代替迁移或权限核验。
- 用户于 2026-09-07 明确确认默认兼容 HTTP/HTTPS、不设开关并接受明文风险;部署本版本并执行迁移后,现有 `http://185.216.248.75:9527` 可以使用客户端密钥管理及客户端 API。本机已部署验证,不能据此宣称线上已经可用。HTTP 会明文传输密钥和业务数据,仍建议使用 HTTPS。
- 用户于 2026-09-07 明确确认默认兼容 HTTP/HTTPS、不设开关并接受明文风险;部署本版本并执行迁移后,现有 `http://185.216.248.75:9527` 可以使用客户端密钥管理及客户端 API。2026-09-08 线上部署及未登录拒绝检查已通过,真实密钥读写验收仍待进行。HTTP 会明文传输密钥和业务数据,仍建议使用 HTTPS。
- 客户端密钥功能不依赖 GOAUTO_TRUST_FORWARDED_PROTO、X-Forwarded-Proto 或 Agent HTTP 例外。既有其他路由的协议和代理配置保持不变;不伪造协议头,不新增明文放行开关。
- 代理、APM、应用日志均不得记录 Authorization、创建响应 secret 或原始业务载荷。应用对两类客户端密钥路由跳过旧请求/响应正文日志,使用专用元数据审计;实际代理日志脱敏仍须部署验收。
- 请求审计 status=0 可能表示在途、进程中断或结果审计更新失败;先按请求关联号核对业务结果,不自动重试采购、采集、同步等操作。停用阻止后续认证,不保证取消已开始的业务操作。
@@ -92,4 +92,15 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 真实迁移必须单独授权并先确认唯一待执行版本;常驻 API 加 `-SkipMigration`,日常重启不自动执行未来待审核迁移。#237 迁移 `1788798000000_client_api_key.go` 已执行,sys_migration 记录 `1788798000000`、两张密钥表及管理员菜单已回读;其他角色未新增菜单关联。任务启停状态未改。
- 范围明确的服务控制:`D:/supervisor/supervisord.exe -c D:/supervisor/supervisord.conf ctl status goauto-admin-api goauto-admin-ui`,启动/停止/重启将 status 分别替换为 start/stop/restart。不得为了 GoAuto 重启整个 Supervisor 或其他项目。配置内容变化后需重新读取配置;本次调用 `supervisor.reloadConfig`,再对上述两个程序定向 start,已通过进程命令行核对新目录。
- 日志:`D:/supervisor/logs/goauto-admin-api.log`、`D:/supervisor/logs/goauto-admin-ui.log`;对外分享只能保留脱敏结构摘要。迁移输出不得暴露凭据或业务原文。
- 管理员刷新页面后可进入 `http://127.0.0.1:9527/#/client-keys/index`;菜单缓存未更新时重新登录。已验证列表空态、创建弹窗加载 12 模块及 HTTP 风险提示;未实际创建密钥,真实客户端读写及停用闭环仍待范围明确的验证。线上 GoAuto/Nginx 未重启或发布。
- 管理员刷新页面后可进入 `http://127.0.0.1:9527/#/client-keys/index`;菜单缓存未更新时重新登录。已验证列表空态、创建弹窗加载 12 模块及 HTTP 风险提示;未实际创建密钥,真实客户端读写及停用闭环仍待范围明确的验证。本节为 2026-09-07 本机验证记录;线上已于 2026-09-08 另行授权发布,见上节。
## 蝦皮详情匹配等待与回读(#254)
实现绑定 `9088e6b`(2026-09-10,分支 fix/254-match-loading);已通过合成数据测试,尚未合并 main 或发布线上。
- 本次只完成代码构建与合成测试,未改线上配置、AI设置、常驻服务或Nginx;部署与重启需另行授权。
- 详情匹配新窗口是后端 2T+10 秒、Web POST 2T+20 秒。当前默认API最低写超时校验仍为620秒,这是历史最低值,不保证容纳所有动态预算;发布前读取实际 AI timeoutSeconds、application.writetimeout 和生效代理配置核对。
- 建议API写超时至少为 2T+30 秒,且仍满足原最低620秒;代理读/发送超时需留足对应窗口。T=60/180时原620秒足够容纳应用预算;T=600时至少需要1230秒API写超时及适当更长的代理窗口。不得仅设置较大的AI超时却忽略传输层更早截断。
- 上述是发布核对要求,不是本单已修改/验证线上代理的结论。超大T带来长连接占用,不能用关闭超时来代替正确预算。新增失败回读不代表必须延长任何线上请求。
- 必须配套发布 Server/Web;仅上新版Web连接旧Server时,因缺失派生预算会提示等待时间不可用并停止匹配。原Android不需升级。
+8
View File
@@ -0,0 +1,8 @@
# PDD 首页控件摘要(设备 192.168.0.9:35727)
- 采集时间:2026-09-15
- 包名:`com.xunmeng.pinduoduo`
- 发现语义入口:`content-desc=拍照搜索`
- 发现搜索入口:`content-desc=搜索`
- 结论:PDD 首页存在稳定的“拍照搜索”无障碍语义入口,可作为 #277 的第一步入口探测。
- 未保存坐标、完整控件树、截图、账号或个人数据。
+38
View File
@@ -0,0 +1,38 @@
param(
[string]$BaseUrl = $env:GOAUTO_ADMIN_BASE_URL,
[int]$BatchSize = 100,
[int]$PageSize = 500,
[int]$SleepSeconds = 1,
[switch]$Force,
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrWhiteSpace($BaseUrl) -or [string]::IsNullOrWhiteSpace($env:GOAUTO_ADMIN_TOKEN)) {
throw '请设置 GOAUTO_ADMIN_BASE_URL 与 GOAUTO_ADMIN_TOKEN 环境变量'
}
if ($BatchSize -lt 1 -or $BatchSize -gt 500) { throw 'BatchSize 必须在 1 到 500 之间' }
$headers = @{ Authorization = "Bearer $($env:GOAUTO_ADMIN_TOKEN)" }
$ids = [System.Collections.Generic.List[UInt64]]::new()
for ($page = 1; ; $page++) {
$uri = "$($BaseUrl.TrimEnd('/'))/api/admin/v1/syb-products?parseStatus=failed&page=$page&pageSize=$PageSize"
$response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get
$items = if ($response.data.items) { $response.data.items } elseif ($response.data.list) { $response.data.list } else { @() }
foreach ($item in $items) { if ($item.id) { [void]$ids.Add([UInt64]$item.id) } }
if ($items.Count -lt $PageSize) { break }
}
Write-Host "发现失败记录 $($ids.Count) 条"
if ($DryRun -or $ids.Count -eq 0) { exit 0 }
$ok = 0; $failed = 0
for ($i = 0; $i -lt $ids.Count; $i += $BatchSize) {
$end = [Math]::Min($i + $BatchSize, $ids.Count)
$body = @{ ids = @($ids[$i..($end - 1)]); force = [bool]$Force } | ConvertTo-Json -Compress
try {
$result = Invoke-RestMethod -Uri "$($BaseUrl.TrimEnd('/'))/api/admin/v1/syb-products/reparse-batch" -Headers ($headers + @{ 'Content-Type' = 'application/json' }) -Method Post -Body $body
$rows = @($result.data.results)
$ok += @($rows | Where-Object { $_.newStatus -eq 'success' }).Count
$failed += @($rows | Where-Object { $_.newStatus -ne 'success' }).Count
Write-Host "已处理 $end/$($ids.Count),成功 $ok,仍失败 $failed"
} catch { Write-Warning "批次 $($i + 1)-$end 请求失败:$($_.Exception.Message)" }
if ($SleepSeconds -gt 0 -and $end -lt $ids.Count) { Start-Sleep -Seconds $SleepSeconds }
}
+3 -1
View File
@@ -18,6 +18,7 @@ import (
goautoshopeeproduct "go-admin/app/goauto/shopeeproduct"
goautosybimport "go-admin/app/goauto/sybimport"
goautosybinnercode "go-admin/app/goauto/sybinnercode"
goautosybproductfilter "go-admin/app/goauto/sybproductfilter"
goautosybshop "go-admin/app/goauto/sybshop"
goautotask "go-admin/app/goauto/task"
common "go-admin/common/middleware"
@@ -63,8 +64,9 @@ func InitRouter() {
goautoproduct.InitRouter(r, authMiddleware)
goautoreplacement.InitRouter(r, authMiddleware)
goautorule.InitRouter(r, authMiddleware)
goautoshopeeproduct.InitRouter(r, authMiddleware)
goautoshopeeproduct.InitRouter(r, authMiddleware, goautopurchase.ValidateQuickReplacement)
goautosybimport.InitRouter(r, authMiddleware)
goautosybinnercode.InitRouter(r, authMiddleware)
goautosybshop.InitRouter(r, authMiddleware)
goautosybproductfilter.InitRouter(r, authMiddleware)
}
+24 -19
View File
@@ -3,18 +3,19 @@ package access
import "strings"
const (
ModuleDevices = "devices"
ModulePDDProducts = "pdd_products"
ModuleShopeeProducts = "shopee_products"
ModuleSYBProducts = "syb_products"
ModuleSYBShops = "syb_shops"
ModuleSYBSyncRuns = "syb_sync_runs"
ModuleSYBInnerCodes = "syb_inner_codes"
ModuleCollectionRules = "collection_rules"
ModulePurchaseRules = "purchase_rules"
ModuleCollectionTasks = "collection_tasks"
ModulePurchaseTasks = "purchase_tasks"
ModuleAIMatching = "ai_matching"
ModuleDevices = "devices"
ModulePDDProducts = "pdd_products"
ModuleShopeeProducts = "shopee_products"
ModuleSYBProducts = "syb_products"
ModuleSYBShops = "syb_shops"
ModuleSYBProductFilters = "syb_product_filters"
ModuleSYBSyncRuns = "syb_sync_runs"
ModuleSYBInnerCodes = "syb_inner_codes"
ModuleCollectionRules = "collection_rules"
ModulePurchaseRules = "purchase_rules"
ModuleCollectionTasks = "collection_tasks"
ModulePurchaseTasks = "purchase_tasks"
ModuleAIMatching = "ai_matching"
)
// ModuleDefinition is the single source of truth shared by menu migration,
@@ -77,6 +78,7 @@ var goAutoMenuGroupMetadata = []MenuGroupDefinition{
Sort: 51,
ModuleKeys: []string{
ModuleSYBShops,
ModuleSYBProductFilters,
ModuleCollectionRules,
ModulePurchaseRules,
ModuleDevices,
@@ -91,13 +93,14 @@ var goAutoModuleMetadata = []ModuleDefinition{
{Key: ModuleShopeeProducts, Title: "虾皮商品", Path: "/shopee-products", RouteName: "GoAutoShopeeProducts", Component: "/goauto/shopee-products/index", Icon: "goods", Sort: 52, PurchaserDefault: true},
{Key: ModuleSYBProducts, Title: "SYB 商品", Path: "/syb-products", RouteName: "GoAutoSybProducts", Component: "/goauto/syb-products/index", Icon: "tickets", Sort: 53, PurchaserDefault: true},
{Key: ModuleSYBShops, Title: "SYB 店铺", Path: "/syb-shops", RouteName: "GoAutoSybShops", Component: "/goauto/syb-shops/index", Icon: "shopping", Sort: 54, PurchaserDefault: true},
{Key: ModuleSYBSyncRuns, Title: "SYB 同步记录", Path: "/syb-sync-runs", RouteName: "GoAutoSybSyncRuns", Component: "/goauto/syb-sync-runs/index", Icon: "time", Sort: 55, PurchaserDefault: true},
{Key: ModuleSYBInnerCodes, Title: "档口入库码", Path: "/syb-inner-codes", RouteName: "GoAutoSybInnerCodes", Component: "/goauto/syb-inner-codes/index", Icon: "list", Sort: 56, PurchaserDefault: true},
{Key: ModuleCollectionRules, Title: "采集规则", Path: "/collection-rules", RouteName: "GoAutoCollectionRules", Component: "/goauto/collection-rules/index", Icon: "code", Sort: 57, PurchaserDefault: true},
{Key: ModulePurchaseRules, Title: "采购规则", Path: "/purchase-rules", RouteName: "GoAutoPurchaseRules", Component: "/goauto/purchase-rules/index", Icon: "setting", Sort: 58, PurchaserHardHidden: true},
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 59, PurchaserDefault: true},
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 60, PurchaserDefault: true},
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 61, PurchaserHardHidden: true},
{Key: ModuleSYBProductFilters, Title: "SYB 商品过滤", Path: "/syb-product-filters", RouteName: "GoAutoSybProductFilters", Component: "/goauto/syb-product-filters/index", Icon: "filter", Sort: 55, PurchaserDefault: true},
{Key: ModuleSYBSyncRuns, Title: "SYB 同步记录", Path: "/syb-sync-runs", RouteName: "GoAutoSybSyncRuns", Component: "/goauto/syb-sync-runs/index", Icon: "time", Sort: 56, PurchaserDefault: true},
{Key: ModuleSYBInnerCodes, Title: "档口入库码", Path: "/syb-inner-codes", RouteName: "GoAutoSybInnerCodes", Component: "/goauto/syb-inner-codes/index", Icon: "list", Sort: 57, PurchaserDefault: true},
{Key: ModuleCollectionRules, Title: "采集规则", Path: "/collection-rules", RouteName: "GoAutoCollectionRules", Component: "/goauto/collection-rules/index", Icon: "code", Sort: 58, PurchaserDefault: true},
{Key: ModulePurchaseRules, Title: "采购规则", Path: "/purchase-rules", RouteName: "GoAutoPurchaseRules", Component: "/goauto/purchase-rules/index", Icon: "setting", Sort: 59, PurchaserHardHidden: true},
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 60, PurchaserDefault: true},
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 61, PurchaserDefault: true},
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 62, PurchaserHardHidden: true},
}
// GoAutoModules returns independent copies so callers cannot mutate the
@@ -144,6 +147,8 @@ func moduleKeyForAPI(path string) string {
return ModuleSYBProducts
case strings.HasPrefix(path, "/api/admin/v1/syb-shops"):
return ModuleSYBShops
case strings.HasPrefix(path, "/api/admin/v1/syb-product-filters"):
return ModuleSYBProductFilters
case strings.HasPrefix(path, "/api/admin/v1/syb-inner-codes"):
return ModuleSYBInnerCodes
case strings.HasPrefix(path, "/api/admin/v1/collection-rules"):
+5 -5
View File
@@ -10,8 +10,8 @@ import (
func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
modules := GoAutoModules()
if len(modules) != 12 {
t.Fatalf("got %d modules, want 12", len(modules))
if len(modules) != 13 {
t.Fatalf("got %d modules, want 13", len(modules))
}
owners := make(map[string]int)
@@ -56,8 +56,8 @@ func TestGoAutoMenuSortsFitMySQLSignedTinyInt(t *testing.T) {
}
previous = module.Sort
}
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 61 {
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..61", modules[0].Sort, modules[len(modules)-1].Sort)
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 62 {
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..62", modules[0].Sort, modules[len(modules)-1].Sort)
}
}
@@ -72,7 +72,7 @@ func TestGoAutoMenuGroupsCoverModulesExactlyOnce(t *testing.T) {
wantOrder := [][]string{
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks},
{ModuleSYBShops, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
{ModuleSYBShops, ModuleSYBProductFilters, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
}
seen := make(map[string]int)
for groupIndex, group := range groups {
+5
View File
@@ -77,6 +77,10 @@ var AdminAPIs = []APIPermission{
{"启停 SYB 店铺", "/api/admin/v1/syb-shops/:shopId/enabled", "PATCH", false},
{"删除 SYB 店铺", "/api/admin/v1/syb-shops/:shopId", "DELETE", false},
{"从 SYB 发现店铺", "/api/admin/v1/syb-shops/discover", "POST", false},
{"查看 SYB 商品过滤", "/api/admin/v1/syb-product-filters", "GET", true},
{"新增 SYB 商品过滤", "/api/admin/v1/syb-product-filters", "POST", false},
{"启停 SYB 商品过滤", "/api/admin/v1/syb-product-filters/:filterId/enabled", "PATCH", false},
{"删除 SYB 商品过滤", "/api/admin/v1/syb-product-filters/:filterId", "DELETE", false},
{"查看采集规则模板", "/api/admin/v1/collection-rules/templates/:templateId", "GET", true},
{"查看采集规则", "/api/admin/v1/collection-rules", "GET", true},
@@ -94,6 +98,7 @@ var AdminAPIs = []APIPermission{
{"查看采集任务", "/api/admin/v1/collection-tasks", "GET", true},
{"创建采集任务", "/api/admin/v1/collection-tasks", "POST", true},
{"批量创建采集任务", "/api/admin/v1/collection-tasks/batch", "POST", true},
{"批量创建图搜采集任务", "/api/admin/v1/collection-tasks/image-search/batch", "POST", true},
{"查看采集任务详情", "/api/admin/v1/collection-tasks/:taskId", "GET", true},
{"重置采集任务", "/api/admin/v1/collection-tasks/:taskId/reset", "POST", true},
{"删除采集任务", "/api/admin/v1/collection-tasks/:taskId", "DELETE", true},
+8 -6
View File
@@ -15,12 +15,14 @@ func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
func TestPurchaserExcludesAdministratorOperations(t *testing.T) {
denied := map[string]bool{
"POST /api/admin/v1/devices/:deviceId/disable": true,
"POST /api/admin/v1/syb-shops/discover": true,
"POST /api/admin/v1/collection-rules": true,
"PUT /api/admin/v1/ai-matching-settings": true,
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
"GET /api/admin/v1/shopee-spec-auto-match/runs/latest": true,
"POST /api/admin/v1/devices/:deviceId/disable": true,
"POST /api/admin/v1/syb-shops/discover": true,
"POST /api/admin/v1/syb-product-filters": true,
"PATCH /api/admin/v1/syb-product-filters/:filterId/enabled": true,
"POST /api/admin/v1/collection-rules": true,
"PUT /api/admin/v1/ai-matching-settings": true,
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
"GET /api/admin/v1/shopee-spec-auto-match/runs/latest": true,
}
for _, permission := range PurchaserAPIs() {
if denied[permission.Method+" "+permission.Path] {
+23 -1
View File
@@ -15,6 +15,7 @@ var (
traditionalToSimplified *gocc.OpenCC
traditionalErr error
weightPattern = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*(?:[-~~至到]\s*(\d+(?:\.\d+)?))?\s*(公斤|千克|kg|斤)`)
sizeTokenPattern = regexp.MustCompile(`(?i)^(?:xxxs|xxl|xxxl|xxxxl|[2-9]xl|xs|s|m|l|xl|\d+xl)\b`)
)
// Normalize makes a deterministic comparison key. It intentionally removes
@@ -80,7 +81,7 @@ func normalizeWeight(raw string) string {
// deliberately treated as no match instead of picking the first label.
func DeterministicMatch(request MatchRequest) (MatchResult, bool) {
color, colorOK := exactCandidate(request.TargetColor, request.Colors)
size, sizeOK := exactCandidate(request.TargetSize, request.Sizes)
size, sizeOK := exactSizeCandidate(request.TargetSize, request.Sizes)
if !colorOK || !sizeOK {
return MatchResult{}, false
}
@@ -88,6 +89,27 @@ func DeterministicMatch(request MatchRequest) (MatchResult, bool) {
return result, true
}
func exactSizeCandidate(target string, candidates []string) (string, bool) {
key := sizeTokenPattern.FindString(strings.ToLower(Normalize(target)))
if key == "" {
return exactCandidate(target, candidates)
}
matches := make([]string, 0, 1)
seen := map[string]bool{}
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" || sizeTokenPattern.FindString(strings.ToLower(Normalize(candidate))) != key || seen[candidate] {
continue
}
seen[candidate] = true
matches = append(matches, candidate)
}
if len(matches) == 1 {
return matches[0], true
}
return "", false
}
// ManualMatch records an already-confirmed mapping without reinterpreting it.
// The caller owns the human-confirmation check; this helper only creates the
// same bounded task snapshot shape used by automatic decisions.
+1 -1
View File
@@ -33,7 +33,7 @@ func fixture(t *testing.T) (*gorm.DB, clientkey.Service) {
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
db, s := fixture(t)
routes := Inventory()
if len(s.Modules) != 12 {
if len(s.Modules) != 13 {
t.Fatal("menu groups lost")
}
router := gin.New()
+1
View File
@@ -95,6 +95,7 @@ func Inventory() []Endpoint {
{"GET", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "read", t.AdminDetail},
{"POST", "/collection-tasks", access.ModuleCollectionTasks, "collect", t.AdminCreate},
{"POST", "/collection-tasks/batch", access.ModuleCollectionTasks, "collect", t.AdminBatchCreate},
{"POST", "/collection-tasks/image-search/batch", access.ModuleCollectionTasks, "collect", t.AdminBatchCreateImageSearch},
{"POST", "/collection-tasks/:taskId/reset", access.ModuleCollectionTasks, "collect", t.AdminReset},
{"DELETE", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "delete", t.AdminDelete},
{"GET", "/purchase-tasks", access.ModulePurchaseTasks, "read", buy.AdminList},
+11
View File
@@ -4,6 +4,7 @@ import (
"strings"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybshop"
"gorm.io/gorm"
)
@@ -41,6 +42,7 @@ func MigratedModels() []any {
&models.SYBSpecAIParseWorkItem{},
&models.SYBSession{},
&models.SYBShop{},
&models.SYBProductFilter{},
&models.SYBSyncRun{},
&models.SYBInnerCodeRecord{},
&models.SYBInnerCodeItem{},
@@ -85,6 +87,15 @@ func Migrate(db *gorm.DB) error {
Update("task_type", models.PurchaseTaskTypeSYBOrder).Error; err != nil {
return err
}
// #269: seed immutable structure判据 and the initial keyword safety net.
seed := []struct{ kind, keyword string }{{"char", "-"}, {"char", "#"}, {"keyword", "CVC短袖220斤"}, {"keyword", "180克純棉圓領短袖"}, {"keyword", "拼色純棉短袖"}, {"keyword", "300斤牛奶絲圓領"}, {"keyword", "印花大學T"}, {"keyword", "印花帽T"}}
for _, item := range seed {
norm := sybshop.Normalize(item.keyword)
row := models.SYBProductFilter{Kind: item.kind, Keyword: item.keyword, NormalizedKeyword: norm, Enabled: true}
if err := db.Where("kind = ? AND normalized_keyword = ?", item.kind, norm).FirstOrCreate(&row).Error; err != nil {
return err
}
}
return ensureMySQLDirectSelectConstraint(db)
}
+57 -25
View File
@@ -20,6 +20,7 @@ const (
CollectionTaskSourceAdmin = "admin"
CollectionTaskSourceAgentCurrentPage = "agent_current_page"
CollectionTaskSourceImageSearch = "image_search"
)
// AgentDevice identifies one Agent installation. InstallID is generated by the
@@ -181,13 +182,14 @@ type CollectionTask struct {
Rule CollectionRule `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
DeviceID *uint64 `json:"deviceId" gorm:"index;uniqueIndex:ux_collection_task_running_device,priority:1"`
Device *AgentDevice `json:"-"`
Source string `json:"source" gorm:"size:32;not null;default:admin;index;check:ck_collection_task_source,source IN ('admin','agent_current_page')"`
Source string `json:"source" gorm:"size:32;not null;default:admin;index;check:ck_collection_task_source,source IN ('admin','agent_current_page','image_search')"`
Status string `json:"status" gorm:"size:24;not null;index;check:ck_collection_task_status,status IN ('pending','running','completed','completed_partial','failed')"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_active_product,priority:2;check:ck_collection_task_active_slot,(status IN ('pending','running') AND active_slot = 1) OR (status NOT IN ('pending','running') AND active_slot IS NULL)"`
DeviceRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_running_device,priority:2;check:ck_collection_task_device_run_slot,(status = 'running' AND device_id IS NOT NULL AND device_run_slot = 1) OR (status <> 'running' AND device_run_slot IS NULL)"`
URLSnapshot string `json:"urlSnapshot" gorm:"type:text;not null"`
GoodsIDSnapshot string `json:"goodsIdSnapshot" gorm:"size:32;not null;index"`
RuleSnapshot string `json:"ruleSnapshot" gorm:"type:text;not null"`
ImageSearchSnapshot *string `json:"-" gorm:"type:text"`
AttemptNumber int `json:"attemptNumber" gorm:"not null;default:1;check:ck_collection_task_attempt_number,attempt_number >= 1"`
LeaseExpiresAt *time.Time `json:"leaseExpiresAt" gorm:"index"`
LeaseVersion uint64 `json:"leaseVersion" gorm:"not null;default:0"`
@@ -416,7 +418,8 @@ type ShopeeProduct struct {
// PDDProductID stays nullable and must NOT be unique: one Shopee product maps
// to at most one PDD product, but several Shopee products may share the same
// PDD product.
PDDProductID *uint64 `json:"pddProductId" gorm:"index"`
PDDProductID *uint64 `json:"pddProductId" gorm:"index"`
ImageSearchLinked bool `json:"imageSearchLinked" gorm:"not null;default:false;index"`
// ImageURL holds the SYB-provided reference image URL. It is written by the
// #41 import and may be overridden manually; the product domain never joins
// syb_products at read time.
@@ -455,7 +458,7 @@ func (product *ShopeeProduct) BeforeCreate(_ *gorm.DB) error {
// Parse status for a SYBProduct's productSpec extraction. See sybimport.Parse.
const (
SYBParseStatusSuccess = "success"
SYBParseStatusUncertain = "uncertain"
SYBParseStatusUncertain = "uncertain" // legacy input only; persistence normalizes to failed
SYBParseStatusFailed = "failed"
)
@@ -600,32 +603,61 @@ type SYBShop struct {
func (SYBShop) TableName() string { return "syb_shop" }
// SYBProductFilter controls which SYB detail variationSku values are skipped.
// Char rows are seeded business判据 and may only be enabled/disabled; keyword
// rows are operator-maintained. DeletedFlag keeps the composite unique key
// reusable after a keyword is soft-deleted.
type SYBProductFilter struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Kind string `json:"kind" gorm:"size:16;not null;index;uniqueIndex:ux_syb_product_filter_key,priority:1;check:ck_syb_product_filter_kind,kind IN ('char','keyword')"`
// Keyword and NormalizedKeyword are sized, not text: MySQL refuses a
// BLOB/TEXT column in a key specification without a prefix length, so the
// unique index below cannot be built on a text column (Error 1170). SQLite
// accepts either, which is why only a real MySQL migration surfaces this.
Keyword string `json:"keyword" gorm:"size:200;not null"`
NormalizedKeyword string `json:"normalizedKeyword" gorm:"size:200;not null;uniqueIndex:ux_syb_product_filter_key,priority:2"`
Enabled bool `json:"enabled" gorm:"not null;default:true"`
LastHitAt *time.Time `json:"lastHitAt"`
LastHitCount *int `json:"lastHitCount"`
Note *string `json:"note" gorm:"type:text"`
LastChangedBy string `json:"lastChangedBy" gorm:"size:128;not null;default:''"`
LastChangedAt *time.Time `json:"lastChangedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
DeletedFlag uint64 `json:"-" gorm:"not null;default:0;uniqueIndex:ux_syb_product_filter_key,priority:3"`
}
func (SYBProductFilter) TableName() string { return "syb_product_filter" }
// SYBSyncRun records one asynchronous SYB import. ActiveSlot is set only while
// a run is active; its unique index is the database-level single-flight guard
// across processes and is cleared when the run reaches a terminal state.
type SYBSyncRun struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
DateFrom string `json:"dateFrom" gorm:"size:10;not null"`
DateTo string `json:"dateTo" gorm:"size:10;not null"`
Status string `json:"status" gorm:"size:16;not null;index"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_syb_sync_run_active_slot"`
DaysTotal int `json:"daysTotal" gorm:"not null;default:0"`
DaysProcessed int `json:"daysProcessed" gorm:"not null;default:0"`
OrderCount int `json:"orderCount" gorm:"not null;default:0"`
DetailCount int `json:"detailCount" gorm:"not null;default:0"`
AcceptedCount int `json:"acceptedCount" gorm:"not null;default:0"`
ShopSkipped int `json:"shopSkipped" gorm:"not null;default:0"`
Created int `json:"created" gorm:"column:created_count;not null;default:0"`
Updated int `json:"updated" gorm:"column:updated_count;not null;default:0"`
ShopFilterHash string `json:"shopFilterHash" gorm:"size:64;not null"`
ShopBreakdownJSON string `json:"-" gorm:"type:text;not null"`
ErrorMessage string `json:"errorMessage" gorm:"size:1000;not null;default:''"`
OperatorID uint64 `json:"operatorId" gorm:"not null;default:0"`
OperatorName string `json:"operatorName" gorm:"size:128;not null;default:''"`
StartedAt time.Time `json:"startedAt" gorm:"not null"`
FinishedAt *time.Time `json:"finishedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
DateFrom string `json:"dateFrom" gorm:"size:10;not null"`
DateTo string `json:"dateTo" gorm:"size:10;not null"`
Status string `json:"status" gorm:"size:16;not null;index"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_syb_sync_run_active_slot"`
DaysTotal int `json:"daysTotal" gorm:"not null;default:0"`
DaysProcessed int `json:"daysProcessed" gorm:"not null;default:0"`
OrderCount int `json:"orderCount" gorm:"not null;default:0"`
DetailCount int `json:"detailCount" gorm:"not null;default:0"`
AcceptedCount int `json:"acceptedCount" gorm:"not null;default:0"`
ShopSkipped int `json:"shopSkipped" gorm:"not null;default:0"`
CharFilterSkipped int `json:"charFilterSkipped" gorm:"column:char_filter_skipped;not null;default:0"`
KeywordFilterSkipped int `json:"keywordFilterSkipped" gorm:"column:keyword_filter_skipped;not null;default:0"`
Created int `json:"created" gorm:"column:created_count;not null;default:0"`
Updated int `json:"updated" gorm:"column:updated_count;not null;default:0"`
ShopFilterHash string `json:"shopFilterHash" gorm:"size:64;not null"`
ShopBreakdownJSON string `json:"-" gorm:"type:text;not null"`
ErrorMessage string `json:"errorMessage" gorm:"size:1000;not null;default:''"`
OperatorID uint64 `json:"operatorId" gorm:"not null;default:0"`
OperatorName string `json:"operatorName" gorm:"size:128;not null;default:''"`
StartedAt time.Time `json:"startedAt" gorm:"not null"`
FinishedAt *time.Time `json:"finishedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SYBSyncRun) TableName() string { return "syb_sync_run" }
+8
View File
@@ -422,6 +422,14 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
}
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
}
// Readiness must agree with the mapping shown in the product detail. A
// deterministic suggestion is only a preview; it is not a persisted,
// confirmed mapping and must not make the row appear purchase-ready.
if source == "unresolved" || !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, item.MappedColor, item.MappedSize) {
// Keep Eligible for the existing live-probe path, but expose the
// unresolved state so the UI cannot label the row purchase-ready.
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "采购规格尚未匹配并保存", "open_mapping"
}
reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor, guard)
if err != nil {
item.ReasonCode, item.Reason, item.NextAction = "PDD_PRICE_MISSING", err.Error(), "open_pdd"
@@ -142,7 +142,7 @@ func TestBatchSpecMatchPersistsExactMatchButPurchaseDoesNotWaitForIt(t *testing.
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 != ProcessStagePurchaseReady {
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
t.Fatalf("live-probe purchase was incorrectly gated by persisted mapping: %+v err=%v", before, err)
}
@@ -181,7 +181,7 @@ 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 != ProcessStagePurchaseReady {
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 were not purchase-ready for live probing: %+v err=%v", before, err)
}
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
@@ -231,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 != ProcessStagePurchaseReady {
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || !item.Eligible || item.ProcessStage != ProcessStageColorMapping {
t.Fatalf("explicit AI eligibility mismatch: %+v", item)
}
}
+2 -2
View File
@@ -314,7 +314,7 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
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.EligibleCount != 2 {
t.Fatalf("unresolved rows must be ready for mandatory live probing: %+v", response)
t.Fatalf("unresolved rows remain eligible for live probing but are not purchase-ready: %+v", response)
}
}
@@ -329,7 +329,7 @@ func TestBatchPreviewAllowsIncompleteArchivedCandidatesForMandatoryLiveProbe(t *
}
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 {
if err != nil || len(response.Items) != 1 || !response.Items[0].Eligible || response.Items[0].ProcessStage != ProcessStageManualAction {
t.Fatalf("incomplete archive blocked live probe task creation: %+v err=%v", response, err)
}
}
+76 -20
View File
@@ -8,6 +8,7 @@ import (
"errors"
"strings"
"time"
"unicode"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/device"
@@ -342,22 +343,6 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
now := s.Now()
next := ""
switch req.ResultType {
case "spec_rematch_completed":
if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusRunning || t.SpecDecisionRequestID == nil || t.IrreversibleAt != nil || len(req.ProbedSpecs) == 0 {
return TaskPayload{}, fail(CodeSpecRematchRejected, "当前任务不满足一次现场重新匹配条件")
}
var rematchCount int64
if e := tx.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ? AND result_type = ?", t.ID, "spec_rematch_completed").Count(&rematchCount).Error; e != nil {
return TaskPayload{}, internal(e)
}
if rematchCount > 0 {
return TaskPayload{}, fail(CodeSpecRematchRejected, "该任务的现场重新匹配次数已用尽")
}
next = models.PurchaseTaskStatusSpecProbePending
a.Status = models.PurchaseAttemptStatusCompleted
t.SpecDecisionRequestID = nil
t.SpecDecisionBy = nil
t.MappedColorSnapshot, t.MappedSizeSnapshot, t.SpecSource = "", "", "unresolved"
case "spec_probe_completed":
if len(req.ProbedSpecs) == 0 {
return TaskPayload{}, fail(CodeInvalidRequest, "规格探测结果无效")
@@ -413,6 +398,11 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
case "failed":
next = models.PurchaseTaskStatusFailed
a.Status = models.PurchaseAttemptStatusFailed
// `[必须]` Write the failure onto the attempt too, not only the task.
// Without it every attempt produced by this branch carries a NULL
// error_code, so attempt-level statistics silently omit them — on
// production the spec-panel failures were invisible that way (#271).
a.ErrorCode, a.ErrorMessage = &req.ErrorCode, &req.ErrorMessage
t.ErrorCode = &req.ErrorCode
t.ErrorMessage = &req.ErrorMessage
t.ActualUnitPriceCent = req.ActualUnitPriceCent
@@ -429,7 +419,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
a.ResultHash = &digest
a.ResultType = &req.ResultType
a.FinishedAt = &now
if req.ResultType == "spec_probe_completed" || req.ResultType == "spec_rematch_completed" {
if req.ResultType == "spec_probe_completed" {
a.SpecDecisionSnapshot = string(req.ProbedSpecs)
}
if e := tx.Omit("Task").Save(a).Error; e != nil {
@@ -440,7 +430,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
}
return valuePayload(s, t, a, false)
})
if err != nil || (req.ResultType != "spec_probe_completed" && req.ResultType != "spec_rematch_completed") || payload.Replayed || payload.Status != models.PurchaseTaskStatusSpecProbePending {
if err != nil || req.ResultType != "spec_probe_completed" || payload.Replayed || payload.Status != models.PurchaseTaskStatusSpecProbePending {
return payload, err
}
return s.resolveProbedSpecs(ctx, taskID, req.TaskAttemptID, req.ProbedSpecs)
@@ -463,6 +453,26 @@ var allowedOrderUnknownFailures = map[string]string{
CodeOrderUnpaidMissing: "订单详情缺少待付款状态证据",
}
// orderFailureMessageLimit matches the error_message column width on both
// purchase_task and purchase_task_attempt (size:1000). The canonical sentence is
// never sacrificed to fit: only the appended agent evidence is truncated.
const orderFailureMessageLimit = 1000
// normalizeOrderUnknownFailure keeps the canonical sentence as the message
// people read, and appends the agent's own diagnostic behind it.
//
// `[必须]` The canonical text stays authoritative. The agent supplies free text,
// so it must not become the primary description of what happened, and the UI
// wording has to stay stable per error code.
//
// `[必须]` The agent's evidence is nevertheless kept. Discarding it left the
// PAYMENT_REPEATED failures unanalysable: #210 computes paymentBackAttempts and
// consecutivePaymentSamplesAfterBack, the agent reports them, and none of the 61
// production failures between 2026-09-07 and 09-11 carried either value, so
// "Back did not work", "the page rendered slower than the ~1.1s window" and "the
// page was the order page but no marker matched" could not be told apart. The
// sibling "failed" branch already stores agent text verbatim, so keeping it here
// introduces no new trust assumption (#271).
func normalizeOrderUnknownFailure(code, message string) (string, string, error) {
code, message = strings.TrimSpace(code), strings.TrimSpace(message)
if code == "" && message == "" {
@@ -472,7 +482,53 @@ func normalizeOrderUnknownFailure(code, message string) (string, string, error)
if !ok || message == "" {
return "", "", fail(CodeInvalidRequest, "订单核单失败阶段无效")
}
return code, canonicalMessage, nil
return code, appendAgentEvidence(canonicalMessage, message), nil
}
// appendAgentEvidence joins the canonical sentence with the agent's diagnostic.
//
// The evidence is flattened to a single line first: agent text reaches the
// purchase list, task detail and exports, and an embedded newline or control
// character would break those renderings.
func appendAgentEvidence(canonical, evidence string) string {
evidence = flattenAgentEvidence(evidence)
if evidence == "" || evidence == canonical {
return canonical
}
room := orderFailureMessageLimit - len([]rune(canonical)) - 1
if room <= 0 {
return canonical
}
if runes := []rune(evidence); len(runes) > room {
if room <= 1 {
return canonical
}
evidence = string(runes[:room-1]) + "…"
}
return canonical + " " + evidence
}
// flattenAgentEvidence removes control characters and collapses whitespace so
// the appended text is always one readable line.
func flattenAgentEvidence(evidence string) string {
var b strings.Builder
lastWasSpace := false
for _, r := range evidence {
switch {
case r == 0xFEFF:
// Zero-width no-break space: invisible, so drop it rather than
// turning it into a space that would look like a stray gap.
case unicode.IsControl(r) || unicode.IsSpace(r):
if !lastWasSpace {
b.WriteRune(' ')
lastWasSpace = true
}
default:
b.WriteRune(r)
lastWasSpace = false
}
}
return strings.TrimSpace(b.String())
}
func (s *Service) ApplySpecDecision(ctx context.Context, taskID uint64, req SpecDecisionRequest) (models.PurchaseTask, bool, error) {
@@ -716,7 +772,7 @@ func ensureAccountFree(tx *gorm.DB, accountID *uint64, taskID uint64, now time.T
return nil
}
func (s *Service) payload(t models.PurchaseTask, a *models.PurchaseTaskAttempt, replayed bool) (*TaskPayload, error) {
p := &TaskPayload{TaskID: t.ID, ExecutionMode: t.ExecutionMode, Status: t.Status, DeviceID: t.DeviceID, PDDProductID: t.PDDProductID, PDDURL: t.PDDURLSnapshot, PDDGoodsID: t.PDDGoodsIDSnapshot, TargetColor: t.TargetColorSnapshot, TargetSize: t.TargetSizeSnapshot, MappedColor: t.MappedColorSnapshot, MappedSize: t.MappedSizeSnapshot, SpecResolutionAllowed: specResolutionAllowed(t), SpecRematchAllowed: t.SpecDecisionRequestID != nil && t.TaskType == models.PurchaseTaskTypeSYBOrder && t.Status == models.PurchaseTaskStatusRunning, Quantity: t.Quantity, MinUnitPriceCent: t.MinUnitPriceCent, MaxUnitPriceCent: t.MaxUnitPriceCent, Currency: t.Currency, AddressSuffix: t.AddressSuffix, RuleSnapshot: json.RawMessage(t.RuleSnapshot), LeaseExpiresAt: t.LeaseExpiresAt, LeaseVersion: t.LeaseVersion, Replayed: replayed}
p := &TaskPayload{TaskID: t.ID, ExecutionMode: t.ExecutionMode, Status: t.Status, DeviceID: t.DeviceID, PDDProductID: t.PDDProductID, PDDURL: t.PDDURLSnapshot, PDDGoodsID: t.PDDGoodsIDSnapshot, TargetColor: t.TargetColorSnapshot, TargetSize: t.TargetSizeSnapshot, MappedColor: t.MappedColorSnapshot, MappedSize: t.MappedSizeSnapshot, SpecResolutionAllowed: specResolutionAllowed(t), Quantity: t.Quantity, MinUnitPriceCent: t.MinUnitPriceCent, MaxUnitPriceCent: t.MaxUnitPriceCent, Currency: t.Currency, AddressSuffix: t.AddressSuffix, RuleSnapshot: json.RawMessage(t.RuleSnapshot), LeaseExpiresAt: t.LeaseExpiresAt, LeaseVersion: t.LeaseVersion, Replayed: replayed}
if a != nil {
p.TaskAttemptID = a.AttemptID
p.AttemptNumber = a.AttemptNumber
@@ -0,0 +1,155 @@
package purchase
import (
"context"
"strings"
"testing"
"go-admin/app/goauto/models"
"github.com/google/uuid"
)
// 这条诊断就是 #210 让 Agent 算出来、却一直没能落库的那一段。
const paymentRepeatAgentMessage = "支付页安全返回后持续无订单证据,已停止自动核单" +
"[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=3]"
func TestNormalizeOrderUnknownFailureKeepsCanonicalAndAgentEvidence(t *testing.T) {
code, message, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, paymentRepeatAgentMessage)
if err != nil {
t.Fatal(err)
}
if code != CodeOrderPaymentRepeat {
t.Fatalf("code changed: %q", code)
}
canonical := allowedOrderUnknownFailures[CodeOrderPaymentRepeat]
if !strings.HasPrefix(message, canonical) {
t.Fatalf("canonical sentence must lead the message, got %q", message)
}
// 没有这两个值就无法区分「Back 没生效」「渲染慢于窗口」「关键词没匹配」。
for _, want := range []string{"paymentBackAttempts=1", "consecutivePaymentSamplesAfterBack=3"} {
if !strings.Contains(message, want) {
t.Fatalf("agent evidence %q missing from %q", want, message)
}
}
}
func TestNormalizeOrderUnknownFailureFlattensAndBounds(t *testing.T) {
canonical := allowedOrderUnknownFailures[CodeOrderPaymentRepeat]
t.Run("控制字符和换行被压平", func(t *testing.T) {
_, message, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, "证据行一\n\t行二\r\n行三\ufeff")
if err != nil {
t.Fatal(err)
}
if strings.ContainsAny(message, "\n\r\t") {
t.Fatalf("message must stay on one line: %q", message)
}
if !strings.Contains(message, "证据行一 行二 行三") {
t.Fatalf("whitespace should collapse to single spaces: %q", message)
}
})
t.Run("超长证据被截断且固定文案完整保留", func(t *testing.T) {
_, message, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, strings.Repeat("证", 4000))
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(message, canonical) {
t.Fatalf("canonical sentence was sacrificed to fit: %q", message)
}
if n := len([]rune(message)); n > orderFailureMessageLimit {
t.Fatalf("message is %d runes, column holds %d", n, orderFailureMessageLimit)
}
if !strings.HasSuffix(message, "…") {
t.Fatalf("truncation should be visible: %q", message[len(message)-30:])
}
})
t.Run("空消息行为不变", func(t *testing.T) {
if _, _, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, ""); code(err) != CodeInvalidRequest {
t.Fatalf("empty agent message must still be rejected, got %v", err)
}
})
t.Run("未批准的错误码仍被拒绝", func(t *testing.T) {
if _, _, err := normalizeOrderUnknownFailure("UNAPPROVED", "whatever"); code(err) != CodeInvalidRequest {
t.Fatalf("unapproved code must be rejected, got %v", err)
}
})
t.Run("证据与固定文案相同时不重复拼接", func(t *testing.T) {
_, message, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, canonical)
if err != nil {
t.Fatal(err)
}
if message != canonical {
t.Fatalf("identical evidence should not be appended twice: %q", message)
}
})
}
// 端到端确认证据真的写进了两张表,而不是只在函数里拼对了。
func TestSubmitOrderResultUnknownPersistsAgentEvidence(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, _ := createLive(t, s, f)
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",
ErrorCode: CodeOrderPaymentRepeat, ErrorMessage: paymentRepeatAgentMessage,
}, f.token); err != nil {
t.Fatal(err)
}
var saved models.PurchaseTask
if err := db.First(&saved, task.ID).Error; err != nil {
t.Fatal(err)
}
var attempt models.PurchaseTaskAttempt
if err := db.Where("task_id = ?", task.ID).Order("attempt_number DESC, id DESC").First(&attempt).Error; err != nil {
t.Fatal(err)
}
for name, got := range map[string]*string{"task": saved.ErrorMessage, "attempt": attempt.ErrorMessage} {
if got == nil {
t.Fatalf("%s kept no error message", name)
}
if !strings.Contains(*got, "consecutivePaymentSamplesAfterBack=3") {
t.Fatalf("%s dropped the agent evidence: %q", name, *got)
}
}
}
// "failed" 分支过去只写任务不写 attempt,attempt.error_code 全是 NULL,
// 导致按 attempt 统计时这些失败是隐形的(线上规格失败即如此)。
func TestSubmitFailedResultRecordsErrorOnAttempt(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, _ := createLive(t, s, f)
started := startLivePurchaseAfterProbe(t, s, f, task)
const failCode = "PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED"
const failMessage = "规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=1]"
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "failed",
ErrorCode: failCode, ErrorMessage: failMessage,
}, f.token); err != nil {
t.Fatal(err)
}
var attempt models.PurchaseTaskAttempt
if err := db.Where("task_id = ?", task.ID).Order("attempt_number DESC, id DESC").First(&attempt).Error; err != nil {
t.Fatal(err)
}
if attempt.ErrorCode == nil || *attempt.ErrorCode != failCode {
t.Fatalf("attempt error code not recorded: %+v", attempt.ErrorCode)
}
if attempt.ErrorMessage == nil || *attempt.ErrorMessage != failMessage {
t.Fatalf("attempt error message not recorded: %+v", attempt.ErrorMessage)
}
}
@@ -0,0 +1,44 @@
package purchase
import (
"context"
"go-admin/app/goauto/shopeeproduct"
"gorm.io/gorm"
)
// Called inside the link transaction, after locking the SYB and Shopee rows.
// Reuse the authoritative stage calculation, without AI or task creation.
func ValidateQuickReplacement(ctx context.Context, tx *gorm.DB, id uint64) error {
s := NewService(tx)
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
if err != nil {
return err
}
guard, _, err := s.readOnlyPriceGuard(ctx)
if err != nil {
return err
}
preview := s.previewFromDataset(id, dataset, guard)
qualification := aiMatchQualificationForDataset(id, dataset)
preview.AIMatchEligible = qualification.Eligible
preview.AIMatchDisabledReason = qualification.DisabledReason
stage := processStageFromDataset(id, dataset, preview)
syb := dataset.sybByID[id]
disabled := false
if syb.ShopeeProductID != nil {
shopee := dataset.shopeeByID[*syb.ShopeeProductID]
if shopee.PDDProductID != nil {
pdd := dataset.pddByID[*shopee.PDDProductID]
disabled = pdd.Status == "disabled"
if _, active := dataset.activeCollectionByPDD[pdd.ID]; active {
return &shopeeproduct.ServiceError{Code: shopeeproduct.CodeLinkConflict, Message: "商品正在采集,请结束后刷新再操作"}
}
}
}
if stage.NextAction == "open_pdd" && (stage.Stage == ProcessStagePDDPending || stage.Stage == ProcessStagePDDCollectionFail ||
(stage.Stage == ProcessStageManualAction && (disabled || preview.ReasonCode == "PDD_PRICE_MISSING"))) {
return nil
}
return &shopeeproduct.ServiceError{Code: shopeeproduct.CodeLinkConflict, Message: "处理阶段已变化,当前不能一键替换,请刷新列表"}
}
@@ -0,0 +1,44 @@
package purchase
import (
"context"
"testing"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func TestQuickReplacementUsesCurrentStage(t *testing.T) {
for _, name := range []string{"pending", "disabled", "price-missing", "ready", "parse-failed", "collecting", "purchase-pending", "purchase-running", "order-created", "order-unknown"} {
t.Run(name, func(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
allow := name == "pending" || name == "disabled" || name == "price-missing"
switch name {
case "pending", "disabled":
db.Model(&f.pdd).Update("status", name)
case "ready":
setCollectedPDDPrice(t, db, f.pdd.ID)
case "parse-failed":
db.Model(&f.syb).Update("parse_status", "failed")
case "collecting":
rule := models.CollectionRule{Name: "synthetic", ContentJSON: "{}"}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.CollectionTask{DeviceID: &f.device.ID, PDDProductID: &f.pdd.ID, RuleID: rule.ID, Status: models.TaskStatusRunning, RuleSnapshot: "{}"}).Error; err != nil {
t.Fatal(err)
}
case "purchase-pending", "purchase-running", "order-created", "order-unknown":
status := map[string]string{"purchase-pending": "pending", "purchase-running": "running", "order-created": "order_created", "order-unknown": "order_result_unknown"}[name]
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&models.PurchaseTask{ExecutionMode: "live", Quantity: 1, SYBProductID: &f.syb.ID, PDDProductID: f.pdd.ID, Status: status, CreateRequestID: name}).Error; err != nil {
t.Fatal(err)
}
}
err := ValidateQuickReplacement(context.Background(), db, f.syb.ID)
if (err == nil) != allow {
t.Fatalf("allowed=%v: %v", allow, err)
}
})
}
}
-2
View File
@@ -21,7 +21,6 @@ const (
CodeRetryUnsafe = "PURCHASE_RETRY_UNSAFE"
CodeRetryStale = "PURCHASE_RETRY_STALE"
CodeSpecReprobeRejected = "PURCHASE_SPEC_REPROBE_REJECTED"
CodeSpecRematchRejected = "PURCHASE_SPEC_REMATCH_REJECTED"
CodeOrderResultUnknown = "PURCHASE_ORDER_RESULT_UNKNOWN"
CodeOrderEmptyTimeout = "PURCHASE_ORDER_EMPTY_TIMEOUT"
CodeOrderChooserBack = "PURCHASE_ORDER_CHOOSER_BACK_FAILED"
@@ -138,7 +137,6 @@ type TaskPayload struct {
MappedColor string `json:"mappedColor"`
MappedSize string `json:"mappedSize"`
SpecResolutionAllowed bool `json:"specResolutionAllowed"`
SpecRematchAllowed bool `json:"specRematchAllowed"`
Quantity int64 `json:"quantity"`
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
@@ -131,6 +131,12 @@ func (service *Service) suggestMappings(ctx context.Context, id uint64, requestC
item.Reason = "保留已确认映射"
item.Apply = true
response.MatchedCount++
case role == RoleColor && deterministicColorMatch(value.Name, candidateNames) != "":
item.PDDValue = deterministicColorMatch(value.Name, candidateNames)
item.Status = "matched"
item.Reason = "格式统一后唯一匹配"
item.Apply = true
response.MatchedCount++
case role == RoleSize && deterministicSizeMatch(value.Name, candidateNames) != "":
item.PDDValue = deterministicSizeMatch(value.Name, candidateNames)
item.Status = "matched"
@@ -237,6 +243,15 @@ func deterministicSizeMatch(value string, candidates []string) string {
return match.MappedSize
}
// Keep the original candidate label; normalization is only a comparison key.
func deterministicColorMatch(value string, candidates []string) string {
match, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetColor: value, Colors: candidates})
if !ok {
return ""
}
return match.MappedColor
}
// aiSuggestErrorMessage turns an aimatching error into a message safe to
// return to the admin UI (no credential, no raw provider body).
func aiSuggestErrorMessage(err error) string {
+30 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
@@ -48,7 +49,20 @@ type autoMatchRef struct {
// before the transaction; the transaction rechecks the complete spec context
// and PDD candidate set so a stale decision can never be written.
func (service *Service) AutoMatchMappings(ctx context.Context, id uint64, request AutoMatchRequest) (AutoMatchResponse, error) {
ctx, cancel := context.WithTimeout(ctx, aimatching.MaxProviderTimeout)
started := time.Now()
// Bound the initial settings lookup too. This budget is specific to this
// composite endpoint; other AI operations retain their existing limits.
ctx, cancel := context.WithTimeout(ctx, 1210*time.Second)
defer cancel()
seconds, err := service.autoMatchTimeout(ctx)
if err != nil {
return AutoMatchResponse{}, err
}
return service.autoMatchMappings(ctx, id, request, time.Duration(seconds)*time.Second-time.Since(started))
}
func (service *Service) autoMatchMappings(ctx context.Context, id uint64, request AutoMatchRequest, budget time.Duration) (AutoMatchResponse, error) {
ctx, cancel := context.WithTimeout(ctx, budget)
defer cancel()
requestID := strings.TrimSpace(request.RequestID)
@@ -248,6 +262,21 @@ func (service *Service) AutoMatchMappings(ctx context.Context, id uint64, reques
return response, nil
}
func autoMatchBudgetSeconds(providerSeconds int) int {
if providerSeconds < 3 || providerSeconds > 600 {
providerSeconds = 15 // same default as AI settings; never allow unbounded waits
}
return providerSeconds*2 + 10
}
func (service *Service) autoMatchTimeout(ctx context.Context) (int, error) {
settings, err := aimatching.NewService(service.DB).Settings(ctx)
if err != nil {
return 0, internalError(err)
}
return autoMatchBudgetSeconds(settings.TimeoutSeconds), nil
}
func autoMatchRefs(specs []SpecDimension, role string) []autoMatchRef {
refs := make([]autoMatchRef, 0)
for _, dimension := range specs {
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"github.com/google/uuid"
@@ -166,7 +167,7 @@ func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) e
}
processed++
service.renewAutoMatchRun(run)
response, matchErr := service.AutoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]})
response, matchErr := service.autoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]}, aimatching.MaxProviderTimeout)
// fingerprint begins with the 64-character context version.
postFingerprint := fingerprint
if next, _, nextErr := service.autoMatchEligibility(ctx, product); nextErr == nil && next != "" {
@@ -0,0 +1,91 @@
package shopeeproduct
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
)
func TestAutoMatchBudgetFollowsProviderSetting(t *testing.T) {
for _, tc := range []struct{ ai, total int }{{3, 16}, {60, 130}, {180, 370}, {600, 1210}, {0, 40}, {601, 40}} {
if got := autoMatchBudgetSeconds(tc.ai); got != tc.total {
t.Fatalf("AI %d: got %d want %d", tc.ai, got, tc.total)
}
}
db := openTestDB(t)
setting := seedEnabledAISetting(t, "https://example.invalid", 0.9)
setting.TimeoutSeconds = 60
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
s := NewService(db)
created, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "budget-test"})
if err != nil {
t.Fatal(err)
}
detail, err := s.DetailWithAutoMatchBudget(context.Background(), created.Product.ID)
if err != nil || detail.AutoMatchTimeoutSeconds != 130 {
t.Fatalf("budget detail: %d %v", detail.AutoMatchTimeoutSeconds, err)
}
body, _ := json.Marshal(detail)
if strings.Contains(string(body), "apiKey") || strings.Contains(string(body), "example.invalid") {
t.Fatal("AI configuration leaked")
}
if err := db.Model(&setting).Update("timeout_seconds", 180).Error; err != nil {
t.Fatal(err)
}
detail, err = s.DetailWithAutoMatchBudget(context.Background(), created.Product.ID)
if err != nil || detail.AutoMatchTimeoutSeconds != 370 {
t.Fatalf("fresh budget: %d %v", detail.AutoMatchTimeoutSeconds, err)
}
}
func TestAutoMatchCompositeBudgetCancelsWithoutSaving(t *testing.T) {
var calls atomic.Int32
responder := chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.96,"reason":"unique match"}]}`)
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) == 1 {
responder(w, r)
return
}
select {
case <-r.Context().Done():
case <-time.After(time.Second):
}
}))
defer provider.Close()
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
setting := seedEnabledAISetting(t, provider.URL, 0.9)
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
s := NewService(db)
created, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "budget-cancel", PDDProductID: &pdd.ID,
Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}}, {Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "加大码", Source: ValueSourceImport}}}}})
if err != nil {
t.Fatal(err)
}
started := time.Now()
_, err = s.autoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}, 100*time.Millisecond)
if err == nil || time.Since(started) > time.Second {
t.Fatalf("budget not enforced: %v", err)
}
if calls.Load() != 2 {
t.Fatalf("expected sequential color and size calls, got %d", calls.Load())
}
detail, err := s.Detail(context.Background(), created.Product.ID)
if err != nil {
t.Fatal(err)
}
if detail.Product.Specs[0].Values[0].Mapping != nil {
t.Fatal("timed-out operation saved mappings")
}
}
@@ -0,0 +1,100 @@
package shopeeproduct
import (
"context"
"testing"
"github.com/google/uuid"
"go-admin/app/goauto/models"
)
func TestColorNormalizedMatchWithoutAIKeepsOriginalLabels(t *testing.T) {
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
pdd.SpecsJSON = `[{"name":"颜色","role":"color","values":[{"name":"260#大红","selectable":true},{"name":"260#墨绿","selectable":true},{"name":"AbC黑色","selectable":true}]}]`
if err := db.Save(&pdd).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
sources := []string{"260#大紅", "260#墨綠", "abc 黑色"}
targets := []string{"260#大红", "260#墨绿", "AbC黑色"}
values := []SpecValue{}
for _, name := range sources {
values = append(values, SpecValue{Name: name, Source: ValueSourceImport})
}
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "normalized-color", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: values}}})
if err != nil {
t.Fatal(err)
}
preview, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
if err != nil {
t.Fatalf("exact colors must not require AI: %v", err)
}
for i, item := range preview.Items {
if item.Status != "matched" || !item.Apply || item.ValueName != sources[i] || item.PDDValue != targets[i] {
t.Fatalf("unexpected preview: %+v", item)
}
}
if len(preview.Items) != 3 || preview.MatchedCount != 3 {
t.Fatal("missing exact matches")
}
var stored models.ShopeeProduct
if err := db.First(&stored, created.Product.ID).Error; err != nil {
t.Fatal(err)
}
specs, err := Unmarshal(stored.SpecsJSON)
if err != nil {
t.Fatal(err)
}
for _, value := range specs[0].Values {
if value.Mapping != nil {
t.Fatal("preview wrote mappings")
}
}
result, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion})
if err != nil {
t.Fatal(err)
}
if result.ConfirmedCount != 3 || result.UnmatchedCount != 0 {
t.Fatalf("unexpected counts: %+v", result)
}
for i, value := range result.Product.Specs[0].Values {
m := value.Mapping
if value.Name != sources[i] || m == nil || m.PDDValue != targets[i] || m.Source != MappingSourceExactMatch || m.Status != MappingStatusConfirmed {
t.Fatalf("incorrect persisted mapping: %+v", value)
}
}
var after models.PDDProduct
if err := db.First(&after, pdd.ID).Error; err != nil {
t.Fatal(err)
}
if after.SpecsJSON != pdd.SpecsJSON {
t.Fatal("changed original PDD specs")
}
}
func TestColorNormalizedAmbiguityAndUnavailableStillRequireAI(t *testing.T) {
for _, tc := range []struct{ name, source, specs string }{
{"ambiguous", "大紅", `[{"name":"颜色","role":"color","values":[{"name":"大红","selectable":true},{"name":"大紅","selectable":true}]}]`},
{"unavailable", "大紅", `[{"name":"颜色","role":"color","values":[{"name":"大红","selectable":false},{"name":"黑色","selectable":true}]}]`},
{"unmatched", "深紅", `[{"name":"颜色","role":"color","values":[{"name":"大红","selectable":true}]}]`},
} {
t.Run(tc.name, func(t *testing.T) {
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
pdd.SpecsJSON = tc.specs
if err := db.Save(&pdd).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: tc.name, PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: tc.source, Source: ValueSourceImport}}}}})
if err != nil {
t.Fatal(err)
}
_, err = service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
if err == nil || errCode(t, err) != CodeAIUnavailable {
t.Fatalf("must fall back to AI, not guess: %v", err)
}
})
}
}
+23 -3
View File
@@ -14,7 +14,10 @@ import (
"gorm.io/gorm"
)
type Handler struct{ DB *gorm.DB }
type Handler struct {
DB *gorm.DB
ReplacementEligibility ReplacementEligibility
}
func (handler Handler) List(c *gin.Context) {
page, err := queryInt(c.Query("page"), 1)
@@ -31,8 +34,18 @@ func (handler Handler) List(c *gin.Context) {
if !ok {
return
}
var imageSearchLinked *bool
if raw := strings.TrimSpace(c.Query("imageSearchLinked")); raw != "" {
value, parseErr := strconv.ParseBool(raw)
if parseErr != nil {
writeError(c, invalidRequest("imageSearchLinked 必须是布尔值"))
return
}
imageSearchLinked = &value
}
response, err := service.List(c.Request.Context(), ListRequest{
Page: page, PageSize: pageSize, Keyword: c.Query("keyword"), Status: strings.TrimSpace(c.Query("status")),
ImageSearchLinked: imageSearchLinked,
})
if err != nil {
writeError(c, err)
@@ -64,7 +77,7 @@ func (handler Handler) Detail(c *gin.Context) {
if !ok {
return
}
response, err := service.Detail(c.Request.Context(), id)
response, err := service.DetailWithAutoMatchBudget(c.Request.Context(), id)
respond(c, response, err)
}
@@ -96,10 +109,17 @@ func (handler Handler) LinkPDD(c *gin.Context) {
writeError(c, invalidRequest("请求 JSON 无效"))
return
}
// This composite workflow reads SYB and device context. It must not inherit
// a client key's Shopee-only write grant through the shared handler.
if request.LatestCollection != nil && !strings.HasPrefix(c.FullPath(), "/api/admin/v1/") {
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "一键关联仅限 Admin 入口"})
return
}
service, ok := handler.service(c)
if !ok {
return
}
service.ReplacementEligibility = handler.ReplacementEligibility
response, err := service.LinkPDD(c.Request.Context(), id, request)
respond(c, response, err)
}
@@ -467,7 +487,7 @@ func writeError(c *gin.Context, err error) {
status = http.StatusConflict
case CodeProductNotFound, CodePDDProductNotFound, CodeMappingNotFound, CodeValueNotFound:
status = http.StatusNotFound
case CodePDDProductDisabled, CodeSpecContextStale:
case CodePDDProductDisabled, CodeSpecContextStale, CodeLatestCollectionUnavailable, CodeLinkConflict:
status = http.StatusConflict
case CodeAIUnavailable:
status = http.StatusServiceUnavailable
@@ -0,0 +1,190 @@
package shopeeproduct
import (
"context"
"errors"
"go-admin/app/goauto/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const CodeLatestCollectionUnavailable = "LATEST_COLLECTION_UNAVAILABLE"
const CodeLinkConflict = "PDD_LINK_CONFLICT"
type LatestCollectionRequest struct {
SYBProductID uint64 `json:"sybProductId"`
DeviceID uint64 `json:"deviceId"`
SpecContextVersion string `json:"specContextVersion"`
Replacement *LatestCollectionReplacement `json:"replacement,omitempty"`
}
type LatestCollectionReplacement struct {
Preview bool `json:"preview"`
ExpectedPDDProductID uint64 `json:"expectedPddProductId"`
TaskID uint64 `json:"taskId"`
TargetPDDProductID uint64 `json:"targetPddProductId"`
TargetVersion string `json:"targetVersion"`
}
type LatestCollectionSource struct {
TaskID uint64 `json:"taskId"`
DeviceID uint64 `json:"deviceId"`
PDDProductID uint64 `json:"pddProductId"`
RequiresSpecConfirmation bool `json:"requiresSpecConfirmation"`
TargetVersion string `json:"targetVersion,omitempty"`
Title string `json:"title,omitempty"`
GoodsID string `json:"goodsId,omitempty"`
PreviousTitle string `json:"previousTitle,omitempty"`
PreviousGoodsID string `json:"previousGoodsId,omitempty"`
DeviceName string `json:"deviceName,omitempty"`
}
func latestUnavailable(message string) error {
return &ServiceError{Code: CodeLatestCollectionUnavailable, Message: message}
}
// Resolve the candidate exactly once under short row locks; never hold a
// transaction while calling AI. The returned context belongs to this write,
// not a later GET that could silently adopt another operator's association.
func (service *Service) linkLatestCollection(ctx context.Context, id uint64, request LinkPDDRequest) (SaveResponse, error) {
r := request.LatestCollection
if request.PDDProductID != 0 || r.DeviceID == 0 || r.SYBProductID == 0 || r.SpecContextVersion == "" {
return SaveResponse{}, invalidRequest("一键关联需要 SYB、设备及规格版本,不能同时指定 PDD 商品")
}
var response SaveResponse
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var syb models.SYBProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, r.SYBProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("SYB 明细不存在,请刷新列表")
}
return err
}
if syb.ShopeeProductID == nil || *syb.ShopeeProductID != id {
return contextVersionStale()
}
var shopee models.ShopeeProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shopee, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return productNotFound()
}
return err
}
replacement := r.Replacement
var old models.PDDProduct
if replacement != nil {
if shopee.PDDProductID == nil || replacement.ExpectedPDDProductID == 0 || *shopee.PDDProductID != replacement.ExpectedPDDProductID {
return contextVersionStale()
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&old, *shopee.PDDProductID).Error; err != nil {
return err
}
if service.ReplacementEligibility == nil {
return invalidRequest("一键替换入口未配置")
}
if err := service.ReplacementEligibility(ctx, tx, syb.ID); err != nil {
return err
}
if !replacement.Preview && (replacement.TaskID == 0 || replacement.TargetPDDProductID == 0 || replacement.TargetVersion == "") {
return invalidRequest("请先预览并确认替换商品")
}
} else if shopee.PDDProductID != nil {
return &ServiceError{Code: CodeLinkConflict, Message: "商品已有关联,请刷新后查看;一键关联不会覆盖"}
}
if computeSpecContextVersion(shopee.PDDProductID, shopee.SpecsJSON, old.SpecsJSON) != r.SpecContextVersion {
return contextVersionStale()
}
var device models.AgentDevice
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, r.DeviceID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("手机已不存在,请重新选择")
}
return err
}
if device.Status != models.DeviceStatusOnline && device.Status != models.DeviceStatusOffline {
return latestUnavailable("手机已停用,请重新选择")
}
var task models.CollectionTask
query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("device_id = ? AND source = ? AND status IN ?", r.DeviceID, models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial})
if replacement != nil && !replacement.Preview {
query = query.Where("id = ?", replacement.TaskID)
}
err := query.Order("finished_at DESC, id DESC").First(&task).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("这台手机暂无成功的临时采集,请先采集商品")
}
if err != nil {
return err
}
if task.FinishedAt == nil || task.PDDProductID == nil {
return latestUnavailable("最近一次临时采集不完整,请先补采;不会回退关联更早的商品")
}
var pdd models.PDDProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pdd, *task.PDDProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("最近采集的 PDD 商品已不存在")
}
return err
}
if pdd.Status != "active" {
return latestUnavailable("最近采集的 PDD 商品尚不可用,请先检查采集结果")
}
color, err := selectablePDDValues(pdd.SpecsJSON, RoleColor)
if err != nil {
return latestUnavailable("最近采集的 PDD 规格无效,请先补采")
}
size, err := selectablePDDValues(pdd.SpecsJSON, RoleSize)
if err != nil || len(color)+len(size) == 0 {
return latestUnavailable("最近采集的 PDD 缺少可用规格,请先补采")
}
if task.Status == models.TaskStatusCompletedPartial {
if err := validatePartialLinkResult(tx, task, color, size); err != nil {
return err
}
}
targetVersion := computeSpecContextVersion(&pdd.ID, "", pdd.SpecsJSON)
if replacement != nil && !replacement.Preview && (replacement.TargetPDDProductID != pdd.ID || replacement.TargetVersion != targetVersion) {
return contextVersionStale()
}
// Preview never writes. Same-product continuation also preserves the
// existing association and request marker; matching is a separate call.
if replacement == nil || (!replacement.Preview && old.ID != pdd.ID) {
query := tx.Model(&models.ShopeeProduct{}).Where("id = ?", id)
if replacement == nil {
query = query.Where("pdd_product_id IS NULL")
} else {
query = query.Where("pdd_product_id = ?", old.ID)
}
result := query.Updates(map[string]any{"pdd_product_id": pdd.ID, "last_update_request_id": request.RequestID})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return contextVersionStale()
}
}
response, err = NewService(tx).Detail(ctx, id)
if err != nil {
return err
}
response.CollectionSource = &LatestCollectionSource{TaskID: task.ID, DeviceID: device.ID, PDDProductID: pdd.ID, RequiresSpecConfirmation: syb.ParseStatus == models.SYBParseStatusFailed && !syb.ManuallyConfirmed && !syb.AIConfirmed}
if replacement != nil {
response.CollectionSource.TargetVersion = targetVersion
response.CollectionSource.Title = pdd.Title
response.CollectionSource.GoodsID = pdd.GoodsID
response.CollectionSource.PreviousTitle = old.Title
response.CollectionSource.PreviousGoodsID = old.GoodsID
response.CollectionSource.DeviceName = device.Name
}
return nil
})
if err != nil {
var domain *ServiceError
if errors.As(err, &domain) {
return SaveResponse{}, err
}
return SaveResponse{}, internalError(err)
}
return response, nil
}
@@ -0,0 +1,194 @@
package shopeeproduct
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func latestFixture(t *testing.T) (*Service, uint64, LinkPDDRequest, models.CollectionTask) {
t.Helper()
db := openTestDB(t)
device := models.AgentDevice{InstallID: uuid.NewString(), TokenDigest: uuid.NewString(), Status: models.DeviceStatusOffline}
if err := db.Create(&device).Error; err != nil {
t.Fatal(err)
}
rule := models.CollectionRule{Name: "synthetic", ContentJSON: "{}"}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
pdd := seedPDDProduct(t, db, "active")
s := NewService(db)
created, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: uuid.NewString(), Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "黑色", Source: ValueSourceManual}}}}})
if err != nil {
t.Fatal(err)
}
syb := models.SYBProduct{OrderCode: "synthetic", DetailID: 1, Quantity: 1, ParseStatus: "success", ShopeeProductID: &created.Product.ID}
if err := db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
task := models.CollectionTask{DeviceID: &device.ID, RuleID: rule.ID, PDDProductID: &pdd.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted, RuleSnapshot: "{}", FinishedAt: &now}
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
return s, created.Product.ID, LinkPDDRequest{RequestID: uuid.NewString(), LatestCollection: &LatestCollectionRequest{DeviceID: device.ID, SYBProductID: syb.ID, SpecContextVersion: created.Product.SpecContextVersion}}, task
}
func TestLatestCollectionOfflineAndOnlyIfUnlinked(t *testing.T) {
s, id, req, task := latestFixture(t)
result, err := s.LinkPDD(context.Background(), id, req)
if err != nil {
t.Fatal(err)
}
if result.CollectionSource.TaskID != task.ID || *result.Product.PDDProductID != *task.PDDProductID || result.Product.SpecContextVersion == req.LatestCollection.SpecContextVersion {
t.Fatalf("invalid frozen response: %+v", result)
}
// Another request (or an ambiguous network retry) cannot overwrite the link.
req.RequestID = uuid.NewString()
if _, err := s.LinkPDD(context.Background(), id, req); err == nil || errCode(t, err) != CodeLinkConflict {
t.Fatalf("expected conflict: %v", err)
}
other := seedPDDProduct(t, s.DB, "active")
if _, err := s.LinkPDD(context.Background(), id, LinkPDDRequest{RequestID: uuid.NewString(), PDDProductID: other.ID}); err != nil {
t.Fatalf("manual replacement regressed: %v", err)
}
}
func TestLatestCollectionRejectsInvalidContextWithoutWrite(t *testing.T) {
cases := []struct {
name string
change func(*gorm.DB, *LinkPDDRequest, models.CollectionTask)
}{
{"partial", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
db.Model(&task).Update("status", "completed_partial")
}},
{"disabled-device", func(db *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
db.Model(&models.AgentDevice{}).Where("id = ?", r.LatestCollection.DeviceID).Update("status", "disabled")
}},
{"missing-device", func(_ *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) { r.LatestCollection.DeviceID += 100 }},
{"admin-source", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
db.Model(&task).Update("source", "admin")
}},
{"disabled-pdd", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("status", "disabled")
}},
{"empty-specs", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", "[]")
}},
{"stale-version", func(_ *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
r.LatestCollection.SpecContextVersion = "stale"
}},
{"syb-link-changed", func(db *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
db.Model(&models.SYBProduct{}).Where("id = ?", r.LatestCollection.SYBProductID).Update("shopee_product_id", nil)
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s, id, r, task := latestFixture(t)
tc.change(s.DB, &r, task)
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
t.Fatal("unexpected successful write")
}
var product models.ShopeeProduct
s.DB.First(&product, id)
if product.PDDProductID != nil {
t.Fatal("rejected request wrote association")
}
})
}
}
func TestLatestCollectionOrdersByFinishThenIDAndDoesNotSkipPartial(t *testing.T) {
s, id, r, first := latestFixture(t)
newer := first
newer.ID = 0
newer.Status = models.TaskStatusCompletedPartial
if err := s.DB.Create(&newer).Error; err != nil {
t.Fatal(err)
}
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
t.Fatal("skipped latest partial")
}
// A newer ID with an older completion time must not win.
older := newer
older.ID = 0
older.Status = models.TaskStatusCompleted
ts := first.FinishedAt.Add(-time.Hour)
older.FinishedAt = &ts
if err := s.DB.Create(&older).Error; err != nil {
t.Fatal(err)
}
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
t.Fatal("sorted by id instead of completion")
}
if err := s.DB.Model(&newer).Update("status", models.TaskStatusCompleted).Error; err != nil {
t.Fatal(err)
}
result, err := s.LinkPDD(context.Background(), id, r)
if err != nil || result.CollectionSource.TaskID != newer.ID {
t.Fatalf("wrong latest result: %+v %v", result, err)
}
}
func TestLatestCollectionKeepsParseConfirmationBoundary(t *testing.T) {
for _, status := range []string{"success", "uncertain", "failed"} {
t.Run(status, func(t *testing.T) {
s, id, r, _ := latestFixture(t)
s.DB.Model(&models.SYBProduct{}).Where("id = ?", r.LatestCollection.SYBProductID).Update("parse_status", status)
result, err := s.LinkPDD(context.Background(), id, r)
if err != nil || result.CollectionSource.RequiresSpecConfirmation != (status == "failed") {
t.Fatalf("parse boundary: %+v %v", result, err)
}
})
}
}
func TestClientKeyCannotUseAdminCompositeLink(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/api/client/v1/shopee-products/:productId/link-pdd", Handler{}.LinkPDD)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/client/v1/shopee-products/1/link-pdd", strings.NewReader(`{"latestCollection":{"deviceId":1,"sybProductId":1,"specContextVersion":"x"}}`)))
if w.Code != http.StatusForbidden {
t.Fatalf("unexpected status: %d %s", w.Code, w.Body.String())
}
}
// SQLite has no row-level FOR UPDATE. A single test connection serializes
// transactions to exercise concurrent callers; production MySQL locking must
// additionally be verified in its deployment environment.
func TestLatestCollectionConcurrentCallersOnlyOneWins(t *testing.T) {
s, id, r, _ := latestFixture(t)
sqlDB, err := s.DB.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
errors := make(chan error, 2)
start := make(chan struct{})
for range 2 {
go func() { <-start; _, err := s.LinkPDD(context.Background(), id, r); errors <- err }()
}
close(start)
wins := 0
for range 2 {
err := <-errors
if err == nil {
wins++
} else if errCode(t, err) != CodeLinkConflict {
t.Fatal(err)
}
}
if wins != 1 {
t.Fatalf("expected one write, got %d", wins)
}
}
@@ -0,0 +1,93 @@
package shopeeproduct
import (
"encoding/json"
"strings"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
// Only auxiliary metadata may be missing. Validate the source task, not the
// merged product archive, which can retain data from an older collection.
func validatePartialLinkResult(tx *gorm.DB, task models.CollectionTask, colors, sizes map[string]bool) error {
reject := func() error {
return latestUnavailable("最近一次采集的规格或价格仍不完整,请先补采;不会回退关联更早的商品")
}
var missing []string
if task.MissingJSON == nil || json.Unmarshal([]byte(*task.MissingJSON), &missing) != nil || len(missing) == 0 {
return reject()
}
for _, field := range missing {
switch field {
case "salesText", "shopName", "reviewCount":
default:
return reject()
}
}
var dimensions []models.CollectionDimension
if err := tx.Where("task_id = ?", task.ID).Find(&dimensions).Error; err != nil {
return err
}
values := map[string]map[string]bool{}
for _, dimension := range dimensions {
if (dimension.Key != RoleColor && dimension.Key != RoleSize) || values[dimension.Key] != nil {
return reject()
}
var rows []models.CollectionDimensionValue
if err := tx.Where("dimension_id = ?", dimension.ID).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return reject()
}
values[dimension.Key] = map[string]bool{}
for _, row := range rows {
if strings.TrimSpace(row.Value) == "" {
return reject()
}
values[dimension.Key][row.Value] = true
}
}
if len(values) == 0 {
return reject()
}
var skus []models.CollectionSKU
if err := tx.Where("task_id = ? AND available = ?", task.ID, true).Find(&skus).Error; err != nil {
return err
}
if len(skus) == 0 {
return reject()
}
covered := map[string]map[string]bool{RoleColor: {}, RoleSize: {}}
for _, sku := range skus {
var specs map[string]string
if !sku.Complete || sku.PriceCent < 0 || json.Unmarshal([]byte(sku.SpecKey), &specs) != nil || len(specs) != len(values) {
return reject()
}
for role, value := range specs {
if !values[role][value] {
return reject()
}
covered[role][value] = true
}
}
var prices []models.CollectionColorPrice
if err := tx.Where("task_id = ?", task.ID).Find(&prices).Error; err != nil {
return err
}
priced := map[string]bool{}
for _, price := range prices {
if price.PriceCent >= 0 {
priced[price.Color] = true
}
}
for role, candidates := range map[string]map[string]bool{RoleColor: colors, RoleSize: sizes} {
for value := range candidates {
if !covered[role][value] || (role == RoleColor && !priced[value]) {
return reject()
}
}
}
return nil
}
@@ -0,0 +1,121 @@
package shopeeproduct
import (
"context"
"encoding/json"
"testing"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func seedPartialResult(t *testing.T, db *gorm.DB, task models.CollectionTask, missing string, single bool) {
t.Helper()
if err := db.Model(&task).Updates(map[string]any{"status": models.TaskStatusCompletedPartial, "missing_json": missing}).Error; err != nil {
t.Fatal(err)
}
dimensions := map[string][]string{"color": {"黑色"}, "size": {"M", "L", "XL", "2XL"}}
if single {
delete(dimensions, "size")
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", `[{"role":"color","name":"颜色","values":[{"name":"黑色","selectable":true,"priceCent":1000}]}]`).Error; err != nil {
t.Fatal(err)
}
}
for role, values := range dimensions {
d := models.CollectionDimension{TaskID: task.ID, Key: role, Name: role}
if err := db.Create(&d).Error; err != nil {
t.Fatal(err)
}
for _, value := range values {
if err := db.Create(&models.CollectionDimensionValue{DimensionID: d.ID, Value: value}).Error; err != nil {
t.Fatal(err)
}
}
}
if err := db.Create(&models.CollectionColorPrice{TaskID: task.ID, Color: "黑色", PriceCent: 1000}).Error; err != nil {
t.Fatal(err)
}
sizes := dimensions["size"]
if single {
sizes = []string{""}
}
for _, size := range sizes {
specs := map[string]string{"color": "黑色"}
if size != "" {
specs["size"] = size
}
raw, _ := json.Marshal(specs)
if err := db.Create(&models.CollectionSKU{TaskID: task.ID, SpecKey: string(raw), PriceCent: 1000, Available: true, Complete: true}).Error; err != nil {
t.Fatal(err)
}
}
}
func TestPartialAuxiliaryMetadataAllowsLinkAndKeepsStatus(t *testing.T) {
for _, missing := range []string{`["salesText"]`, `["shopName","reviewCount","salesText"]`} {
t.Run(missing, func(t *testing.T) {
s, id, req, task := latestFixture(t)
seedPartialResult(t, s.DB, task, missing, false)
result, err := s.LinkPDD(context.Background(), id, req)
if err != nil || result.CollectionSource.TaskID != task.ID {
t.Fatalf("link: %+v %v", result, err)
}
s.DB.First(&task, task.ID)
if task.Status != models.TaskStatusCompletedPartial || *task.MissingJSON != missing {
t.Fatal("changed historical completeness")
}
})
}
}
func TestPartialMetadataAllowsSingleDimensionAndReplacement(t *testing.T) {
s, id, req, task, old := replacementFixture(t)
seedPartialResult(t, s.DB, task, `["salesText"]`, true)
preview := previewReplacement(t, s, id, &req)
if *preview.Product.PDDProductID != old.ID {
t.Fatal("preview wrote")
}
result, err := s.LinkPDD(context.Background(), id, req)
if err != nil || *result.Product.PDDProductID != *task.PDDProductID {
t.Fatalf("replacement: %+v %v", result, err)
}
}
func TestPartialMissingOrResultDefectsStillReject(t *testing.T) {
for _, scenario := range []string{"[]", "null", "bad-json", `["salesText","size"]`, `["title"]`, `["price:黑色"]`, `["skuLimit:12>8"]`, `["unknown"]`, "sku-incomplete", "sku-unavailable", "sku-invalid", "no-current-price", "old-archive-value"} {
t.Run(scenario, func(t *testing.T) {
s, id, req, task := latestFixture(t)
missing := scenario
if scenario[0] != '[' && scenario != "null" && scenario != "bad-json" {
missing = `["salesText"]`
}
seedPartialResult(t, s.DB, task, missing, false)
switch scenario {
case "sku-incomplete":
s.DB.Model(&models.CollectionSKU{}).Where("task_id = ?", task.ID).Update("complete", false)
case "sku-unavailable":
s.DB.Model(&models.CollectionSKU{}).Where("task_id = ?", task.ID).Update("available", false)
case "sku-invalid":
var sku models.CollectionSKU
if err := s.DB.Where("task_id = ?", task.ID).First(&sku).Error; err != nil {
t.Fatal(err)
}
if err := s.DB.Model(&sku).Update("spec_key", `{}`).Error; err != nil {
t.Fatal(err)
}
case "no-current-price":
s.DB.Model(&models.CollectionColorPrice{}).Where("task_id = ?", task.ID).Update("color", "不是本次颜色")
case "old-archive-value":
s.DB.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", `[{"role":"color","values":[{"name":"旧颜色","selectable":true,"priceCent":1000}]}]`)
}
if _, err := s.LinkPDD(context.Background(), id, req); err == nil {
t.Fatal("accepted incomplete current result")
}
var product models.ShopeeProduct
s.DB.First(&product, id)
if product.PDDProductID != nil {
t.Fatal("rejected request wrote association")
}
})
}
}
@@ -0,0 +1,145 @@
package shopeeproduct
import (
"context"
"testing"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func replacementFixture(t *testing.T) (*Service, uint64, LinkPDDRequest, models.CollectionTask, models.PDDProduct) {
s, id, req, task := latestFixture(t)
old := seedPDDProduct(t, s.DB, "pending")
if err := s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", old.ID).Error; err != nil {
t.Fatal(err)
}
detail, err := s.Detail(context.Background(), id)
if err != nil {
t.Fatal(err)
}
req.LatestCollection.SpecContextVersion = detail.Product.SpecContextVersion
req.LatestCollection.Replacement = &LatestCollectionReplacement{Preview: true, ExpectedPDDProductID: old.ID}
s.ReplacementEligibility = func(context.Context, *gorm.DB, uint64) error { return nil }
return s, id, req, task, old
}
func previewReplacement(t *testing.T, s *Service, id uint64, req *LinkPDDRequest) SaveResponse {
t.Helper()
preview, err := s.LinkPDD(context.Background(), id, *req)
if err != nil {
t.Fatal(err)
}
r := req.LatestCollection.Replacement
r.Preview = false
r.TaskID = preview.CollectionSource.TaskID
r.TargetPDDProductID = preview.CollectionSource.PDDProductID
r.TargetVersion = preview.CollectionSource.TargetVersion
return preview
}
func TestReplacementPreviewIsReadOnlyAndFreezesCandidate(t *testing.T) {
s, id, req, task, old := replacementFixture(t)
preview := previewReplacement(t, s, id, &req)
if *preview.Product.PDDProductID != old.ID {
t.Fatal("preview changed association")
}
var record models.ShopeeProduct
s.DB.First(&record, id)
if *record.PDDProductID != old.ID || record.LastUpdateRequestID != nil {
t.Fatal("preview wrote")
}
newer := task
newer.ID = 0
newer.Status = models.TaskStatusCompletedPartial
if err := s.DB.Create(&newer).Error; err != nil {
t.Fatal(err)
}
result, err := s.LinkPDD(context.Background(), id, req)
if err != nil || *result.Product.PDDProductID != *task.PDDProductID || result.CollectionSource.TaskID != task.ID {
t.Fatalf("frozen candidate: %+v %v", result, err)
}
if _, err := s.LinkPDD(context.Background(), id, req); err == nil {
t.Fatal("duplicate replaced stale association")
}
}
func TestReplacementSameProductDoesNotRebind(t *testing.T) {
s, id, req, task, _ := replacementFixture(t)
s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", *task.PDDProductID)
detail, _ := s.Detail(context.Background(), id)
req.LatestCollection.SpecContextVersion = detail.Product.SpecContextVersion
req.LatestCollection.Replacement.ExpectedPDDProductID = *task.PDDProductID
previewReplacement(t, s, id, &req)
if _, err := s.LinkPDD(context.Background(), id, req); err != nil {
t.Fatal(err)
}
var record models.ShopeeProduct
s.DB.First(&record, id)
if record.LastUpdateRequestID != nil {
t.Fatal("same product wrote association")
}
}
func TestReplacementRejectsChangesAfterPreview(t *testing.T) {
for _, change := range []string{"association", "specs", "target-specs", "task-status", "device", "stage", "missing-validator", "target-id", "task-id"} {
t.Run(change, func(t *testing.T) {
s, id, req, task, old := replacementFixture(t)
previewReplacement(t, s, id, &req)
expected := old.ID
switch change {
case "association":
other := seedPDDProduct(t, s.DB, "active")
expected = other.ID
s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", expected)
case "specs":
s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("specs_json", "[]")
case "target-specs":
s.DB.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", `[{"role":"color","name":"颜色","values":[{"name":"白色","selectable":true}]}]`)
case "task-status":
s.DB.Model(&task).Update("status", "completed_partial")
case "device":
s.DB.Model(&models.AgentDevice{}).Where("id = ?", req.LatestCollection.DeviceID).Update("status", "disabled")
case "stage":
s.ReplacementEligibility = func(context.Context, *gorm.DB, uint64) error { return &ServiceError{Code: CodeLinkConflict} }
case "missing-validator":
s.ReplacementEligibility = nil
case "target-id":
req.LatestCollection.Replacement.TargetPDDProductID++
case "task-id":
req.LatestCollection.Replacement.TaskID++
}
if _, err := s.LinkPDD(context.Background(), id, req); err == nil {
t.Fatal("accepted changed context")
}
var record models.ShopeeProduct
s.DB.First(&record, id)
if *record.PDDProductID != expected {
t.Fatal("overwrote rejected association")
}
})
}
}
func TestReplacementConcurrentConfirmationsOnlyOneWrites(t *testing.T) {
s, id, req, _, _ := replacementFixture(t)
previewReplacement(t, s, id, &req)
connection, err := s.DB.DB()
if err != nil {
t.Fatal(err)
}
connection.SetMaxOpenConns(1) // SQLite serialization, not a MySQL lock claim.
results := make(chan error, 2)
for range 2 {
go func() { _, err := s.LinkPDD(context.Background(), id, req); results <- err }()
}
wins := 0
for range 2 {
if <-results == nil {
wins++
}
}
if wins != 1 {
t.Fatalf("writes=%d", wins)
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
handler := Handler{}
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware, eligibility ReplacementEligibility) {
handler := Handler{ReplacementEligibility: eligibility}
admin := engine.Group("/api/admin/v1/shopee-products").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
adminOnlyRuns := engine.Group("/api/admin/v1/shopee-spec-auto-match/runs").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(middleware.RequireRoleKey("admin"))
adminOnlyRuns.POST("", handler.StartAutoMatchRun)
+32 -7
View File
@@ -57,7 +57,12 @@ func (err *ServiceError) Error() string {
}
func (err *ServiceError) Unwrap() error { return err.Cause }
type Service struct{ DB *gorm.DB }
type ReplacementEligibility func(context.Context, *gorm.DB, uint64) error
type Service struct {
DB *gorm.DB
ReplacementEligibility ReplacementEligibility
}
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
@@ -76,8 +81,10 @@ type ProductView struct {
}
type SaveResponse struct {
Product ProductView `json:"product"`
Replayed bool `json:"replayed,omitempty"`
Product ProductView `json:"product"`
Replayed bool `json:"replayed,omitempty"`
AutoMatchTimeoutSeconds int `json:"autoMatchTimeoutSeconds,omitempty"`
CollectionSource *LatestCollectionSource `json:"collectionSource,omitempty"`
}
type ListItemView struct {
@@ -156,6 +163,16 @@ func (service *Service) Create(ctx context.Context, request CreateRequest) (Save
return SaveResponse{Product: view}, err
}
// DetailWithAutoMatchBudget exposes only a derived time budget, never AI settings.
func (service *Service) DetailWithAutoMatchBudget(ctx context.Context, id uint64) (SaveResponse, error) {
response, err := service.Detail(ctx, id)
if err != nil {
return SaveResponse{}, err
}
response.AutoMatchTimeoutSeconds, err = service.autoMatchTimeout(ctx)
return response, err
}
// ---------------------------------------------------------------- update (archive fields only)
type UpdateRequest struct {
@@ -214,7 +231,8 @@ type ListRequest struct {
// Status filters by lifecycle: "" (default) live only, "deleted" soft-deleted
// only. There is no "all" value: mixing live and deleted rows in one page
// would defeat the point of the filter (#40 prototype: 全部(不含已删除)).
Status string
Status string
ImageSearchLinked *bool
}
func (service *Service) List(ctx context.Context, request ListRequest) (ListResponse, error) {
@@ -240,6 +258,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
like := "%" + request.Keyword + "%"
query = query.Where("shopee_item_id LIKE ? OR title LIKE ? OR shop_name LIKE ?", like, like, like)
}
if request.ImageSearchLinked != nil {
query = query.Where("image_search_linked = ?", *request.ImageSearchLinked)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return ListResponse{}, internalError(err)
@@ -262,8 +283,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
// ---------------------------------------------------------------- link PDD
type LinkPDDRequest struct {
RequestID string `json:"requestId"`
PDDProductID uint64 `json:"pddProductId"`
RequestID string `json:"requestId"`
PDDProductID uint64 `json:"pddProductId"`
LatestCollection *LatestCollectionRequest `json:"latestCollection,omitempty"`
}
// LinkPDD sets or changes the linked PDD product via the search-and-select
@@ -274,6 +296,9 @@ func (service *Service) LinkPDD(ctx context.Context, id uint64, request LinkPDDR
if _, err := uuid.Parse(strings.TrimSpace(request.RequestID)); err != nil {
return SaveResponse{}, invalidRequest("requestId 必须是 UUID")
}
if request.LatestCollection != nil {
return service.linkLatestCollection(ctx, id, request)
}
if request.PDDProductID == 0 {
return SaveResponse{}, invalidRequest("pddProductId 必填")
}
@@ -282,7 +307,7 @@ func (service *Service) LinkPDD(ctx context.Context, id uint64, request LinkPDDR
return SaveResponse{}, err
}
result := db.Model(&models.ShopeeProduct{}).Where("id = ?", id).Updates(map[string]any{
"pdd_product_id": request.PDDProductID, "last_update_request_id": request.RequestID,
"pdd_product_id": request.PDDProductID, "image_search_linked": false, "last_update_request_id": request.RequestID,
})
if result.Error != nil {
return SaveResponse{}, internalError(result.Error)
+3
View File
@@ -84,6 +84,9 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
}
parsed := Parse(detail.ProductSpec)
if parsed.Status == models.SYBParseStatusUncertain {
parsed.Status = models.SYBParseStatusFailed
}
rawJSON, err := normalizeRaw(detail.Raw)
if err != nil {
return ApplyResult{}, err
@@ -171,6 +171,9 @@ func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config
status := SyncRunSucceeded
if err != nil {
status = SyncRunFailed
if report.Created+report.Updated > 0 {
status = SyncRunPartial
}
}
finishCtx, finishCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer finishCancel()
@@ -4,44 +4,9 @@ import (
"context"
"strings"
"testing"
"time"
)
func freezePaginationToday(t *testing.T) {
t.Helper()
previous := syncNow
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
t.Cleanup(func() { syncNow = previous })
}
func TestTodayCrossPageOverlapRestartsWithIndependentIDs(t *testing.T) {
freezePaginationToday(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 4}}
f.pageIDs = func(_ string, start, call int) []int64 {
if call == 2 {
return []int64{1001, 1002}
}
if call > 2 {
return []int64{int64(2000 + start), int64(2001 + start)}
}
return nil
}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 2, 4, 100)
if err != nil || len(rows) != 4 || f.listTotalCalls != 2 {
t.Fatalf("rows=%d totals=%d err=%v", len(rows), f.listTotalCalls, err)
}
if got := strings.Join(f.listCalls, ","); got != "2026-08-29:0,2026-08-29:2,2026-08-29:0,2026-08-29:2" {
t.Fatalf("did not restart at first page: %s", got)
}
for index, row := range rows {
if row.ID != int64(2000+index) {
t.Fatal("rows leaked from abandoned attempt")
}
}
}
func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testing.T) {
freezePaginationToday(t)
func TestTodayOverlapPreservesCommittedPagesWithoutImportingOverlappingPage(t *testing.T) {
f := &fakeSYB{perDay: map[string]int{"2026-08-28": 2, "2026-08-29": 4}}
f.pageIDs = func(date string, start, _ int) []int64 {
if date == "2026-08-29" && start == 2 {
@@ -54,7 +19,7 @@ func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testin
if err == nil {
t.Fatal("overlap was treated as successful sync")
}
for _, token := range []string{"连续 3 次", "跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
for _, token := range []string{"跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
if !strings.Contains(err.Error(), token) {
t.Fatalf("missing %s in %v", token, err)
}
@@ -62,7 +27,7 @@ func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testin
if strings.Contains(err.Error(), "1001") || strings.Contains(err.Error(), "已保存") {
t.Fatalf("unsafe diagnosis/degraded save: %v", err)
}
if len(f.listCalls) != 7 || f.detailCalls != 1 || report.OrderCount != 2 {
if len(f.listCalls) != 3 || f.detailCalls != 2 || report.OrderCount != 4 || report.Created != 2 || report.Updated != 2 {
t.Fatalf("unexpected retry/import boundary: pages=%d details=%d orders=%d", len(f.listCalls), f.detailCalls, report.OrderCount)
}
var count int64
@@ -72,7 +37,6 @@ func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testin
}
func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
freezePaginationToday(t)
for _, tc := range []struct {
name, date, message string
page int
@@ -92,7 +56,7 @@ func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
}
return nil
}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), tc.date, 2, 4, 100)
rows, err := loadDailyList(context.Background(), newSyncClient(t, f), tc.date, 2, 4)
if rows != nil || err == nil || !strings.Contains(err.Error(), tc.message) || len(f.listCalls) != tc.calls || f.listTotalCalls != 0 {
t.Fatalf("unexpected recovery: rows=%d pages=%d totals=%d err=%v", len(rows), len(f.listCalls), f.listTotalCalls, err)
}
+15 -6
View File
@@ -25,7 +25,6 @@ var explicitSizePattern = regexp.MustCompile(`(?i)^(?:均(?:码|碼|号|號)|one
// Observed in the real SYB sample: "黑色+白色【純棉兩件裝】 簡約親膚" strips
// its bracket to "黑色+白色 簡約親膚", which still carries a '+' and internal
// whitespace, so it must not be reported as a confident match.
var ambiguousPattern = regexp.MustCompile(`[++\s]`)
// ParseResult is the color/size candidate extracted from one productSpec
// string, plus how much the caller should trust it.
@@ -73,7 +72,10 @@ func Parse(raw string) ParseResult {
if size == "" {
return ParseResult{Status: models.SYBParseStatusFailed, Note: "productSpec 剥离备注后为空"}
}
return ParseResult{Size: size, Status: models.SYBParseStatusUncertain, Note: "无分隔符,仅识别出单一规格值"}
if explicitSizePattern.MatchString(size) {
return ParseResult{Size: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"}
}
return ParseResult{Color: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"}
}
firstPart := stripBrackets(trimmed[:lastComma])
@@ -81,7 +83,17 @@ func Parse(raw string) ParseResult {
colorPart, sizePart := firstPart, secondPart
if colorPart == "" || sizePart == "" {
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "按逗号拆分后有一侧为空"}
only := colorPart
if only == "" {
only = sizePart
}
if explicitSizePattern.MatchString(only) {
return ParseResult{Size: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"}
}
if only != "" {
return ParseResult{Color: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"}
}
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusFailed, Note: "按逗号拆分后没有可靠规格"}
}
firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart)
if firstIsSize && secondIsSize {
@@ -90,9 +102,6 @@ func Parse(raw string) ParseResult {
if firstIsSize {
colorPart, sizePart = secondPart, firstPart
}
if ambiguousPattern.MatchString(colorPart) {
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "颜色部分含备注文本或多个分隔符,拆分结果可能不准确"}
}
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusSuccess}
}
+3
View File
@@ -77,6 +77,9 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
return fmt.Errorf("syb product %d: stored raw json is not decodable: %w", record.ID, err)
}
parsed := Parse(raw.ProductSpec)
if parsed.Status == models.SYBParseStatusUncertain {
parsed.Status = models.SYBParseStatusFailed
}
outcome.NewStatus = parsed.Status
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
@@ -42,6 +42,9 @@ func RepairReversedSpecRoles(ctx context.Context, db *gorm.DB) (SpecRoleRepairRe
continue
}
parsed := Parse(raw.ProductSpec)
if parsed.Status == models.SYBParseStatusUncertain {
parsed.Status = models.SYBParseStatusFailed
}
if parsed.Status == models.SYBParseStatusFailed || parsed.Color == record.TargetColor ||
parsed.Color != record.TargetSize || parsed.Size != record.TargetColor {
continue
+97 -155
View File
@@ -11,6 +11,7 @@ import (
"time"
"go-admin/app/goauto/sybclient"
"go-admin/app/goauto/sybproductfilter"
"go-admin/app/goauto/sybshop"
"gorm.io/gorm"
@@ -41,8 +42,7 @@ const (
// maxSyncDays bounds one request's window. It is a guard against a typo in
// the date range turning into tens of thousands of remote reads before
// MaxMatches trips.
maxSyncDays = 31
maxTodaySnapshotAttempts = 3
maxSyncDays = 31
)
// SyncReport summarises one sync run.
@@ -53,8 +53,14 @@ type SyncReport struct {
// ShopSkipped how many did not. OrderCount stays the raw total: keeping the
// unfiltered number visible is what makes a shop missing from the
// allow-list obvious rather than silent.
AcceptedCount int `json:"acceptedCount"`
ShopSkipped int `json:"shopSkipped"`
AcceptedCount int `json:"acceptedCount"`
ShopSkipped int `json:"shopSkipped"`
CharFilterSkipped int `json:"charFilterSkipped"`
KeywordFilterSkipped int `json:"keywordFilterSkipped"`
// filterHits accumulates hits per filter rule id so each rule's own
// LastHitCount can be written back. It is unexported because it is run
// bookkeeping, not part of the report shown to people or stored as JSON.
filterHits sybproductfilter.HitCounter
// ShopBreakdown separates accepted and skipped orders for every shop. Without
// it a renamed or newly opened shop is never noticed (#50).
ShopBreakdown map[string]ShopBreakdown `json:"shopBreakdown"`
@@ -82,8 +88,6 @@ type SyncProgress struct {
DaysProcessed int
}
var syncNow = time.Now
type ProgressFunc func(SyncProgress) error
type snapshotDriftError struct {
@@ -94,14 +98,6 @@ type snapshotDriftError struct {
func (err *snapshotDriftError) Error() string { return err.message }
func shanghaiToday() string {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
location = time.FixedZone("Asia/Shanghai", 8*60*60)
}
return syncNow().In(location).Format("2006-01-02")
}
// Sync pulls every shipment order in [dateFrom, dateTo] and folds each detail
// line into the SYB/Shopee archive through ApplyDetail.
//
@@ -109,16 +105,10 @@ func shanghaiToday() string {
// a separate concern (see Connect); a session that dies mid-run surfaces as
// sybclient.ErrSessionInvalid and is treated like any other mid-run failure.
//
// `[必须]` Failure stops the run immediately. Rows already written are NOT
// rolled back — ApplyDetail is idempotent on (order_code, detail_id), so a
// re-run overwrites them rather than duplicating. What must not happen is
// reporting a partial run as a complete one, which would let the missing
// orders go unnoticed forever.
//
// `[必须]` Every day is verified for completeness before anything is written:
// the per-day total is re-read after paging and must not have drifted. SYB's
// list endpoint returns the *page* size in `total` (§4.3, confirmed against
// live data), so the paging loop is driven by listTotal, never by list.total.
// Each validated page is committed independently. Date-local read failures
// preserve committed pages and allow later dates to run. Database, cancellation
// and progress persistence failures stop the run. Completeness is verified at
// the end of each date; partial results must never be reported as full success.
func Sync(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string) (SyncReport, error) {
return SyncWithProgress(ctx, db, client, cfg, dateFrom, dateTo, nil)
}
@@ -136,7 +126,8 @@ func SyncWithProgress(ctx context.Context, db *gorm.DB, client *sybclient.Client
// 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{}, ShopFilterSnapshot: shopSnapshotNames(allowed)}
ShopBreakdown: map[string]ShopBreakdown{}, ShopFilterSnapshot: shopSnapshotNames(allowed),
filterHits: sybproductfilter.HitCounter{}}
dates, err := splitDateRange(dateFrom, dateTo)
if err != nil {
@@ -145,6 +136,10 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl
if len(allowed) == 0 {
return report, ErrNoEnabledShop
}
filters, err := sybproductfilter.LoadEnabled(ctx, db)
if err != nil {
return report, fmt.Errorf("读取商品过滤规则失败: %w", err)
}
report.ShopFilterHash = enabledShopHash(allowed)
emit := func(daysProcessed int) error {
if progress == nil {
@@ -190,106 +185,41 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl
plans = append(plans, dayPlan{date: date, total: total})
}
for dayIndex, plan := range plans {
if plan.total == 0 {
if err := emit(dayIndex + 1); err != nil {
return report, err
}
continue
}
// `[必须]` Completeness first, filtering second. loadDailyList proves the
// day's snapshot is whole; filtering before that would let drift among
// other shops' orders hide a hole in the ones we do want
// (docs/12-syb-erp-interface.md §8).
rows, listErr := loadDailyListWithRecovery(ctx, client, plan.date, pageSize, plan.total, maxMatches)
if listErr != nil && len(rows) == 0 {
return report, listErr
}
report.OrderCount += len(rows)
byID := make(map[int64]sybclient.StockRow, len(rows))
ids := make([]int64, 0, len(rows))
for _, row := range rows {
name := stringField(row.Raw, "shopName")
label := name
if sybshop.IsBlank(label) {
label = "(无店铺名)"
}
display, ok := allowed[sybshop.Normalize(name)]
if ok {
label = display
}
if !ok {
entry := report.ShopBreakdown[label]
entry.Skipped++
report.ShopBreakdown[label] = entry
report.ShopSkipped++
continue
}
entry := report.ShopBreakdown[label]
entry.Accepted++
report.ShopBreakdown[label] = entry
report.AcceptedCount++
byID[row.ID] = row
ids = append(ids, row.ID)
}
if len(ids) == 0 {
if err := emit(dayIndex + 1); err != nil {
return report, err
}
continue
}
for start := 0; start < len(ids); start += detailBatch {
end := start + detailBatch
if end > len(ids) {
end = len(ids)
}
batch := ids[start:end]
details, err := client.DetailListByStock(ctx, batch)
if err != nil {
return report, fmt.Errorf("拉取 %s 货运单明细失败(本次同步停止;"+
"已写入的数据保留,重跑会按 (order_code, detail_id) 覆盖): %w", plan.date, err)
}
if err := validateDetailBatch(batch, details); err != nil {
return report, fmt.Errorf("%s 货运单明细不完整:%w;本次同步停止", plan.date, err)
}
for _, detail := range details {
// `[必须]` Re-check the shop on the detail response. The list said
// this order belongs to an enabled shop; if the detail disagrees,
// the two views are inconsistent and importing it would write a
// row for a shop nobody enabled.
name := stringField(detail.Raw, "shopName")
if sybshop.IsBlank(name) {
moveAcceptedToSkipped(&report, stringField(byID[detail.ID].Raw, "shopName"), "(无店铺名)", allowed)
report.AcceptedCount--
report.ShopSkipped++
continue
}
if _, ok := allowed[sybshop.Normalize(name)]; !ok {
moveAcceptedToSkipped(&report, stringField(byID[detail.ID].Raw, "shopName"), name, allowed)
report.AcceptedCount--
report.ShopSkipped++
continue
}
if err := applyStockDetail(ctx, db, byID[detail.ID], detail, &report); err != nil {
return report, err
}
}
if err := emit(dayIndex); err != nil {
return report, err
}
}
if listErr != nil {
return report, listErr
}
if err := emit(dayIndex + 1); err != nil {
daysCompleted := 0
var failures []error
for _, plan := range plans {
if err := ctx.Err(); err != nil {
return report, err
}
_, dayErr := loadDailyList(ctx, client, plan.date, pageSize, plan.total, func(rows []sybclient.StockRow, page int) error {
report.OrderCount += len(rows)
err := importSyncPage(ctx, db, client, rows, allowed, &report, filters)
if err != nil {
return fmt.Errorf("%s 第 %d 页明细/入库阶段失败: %w", plan.date, page, err)
}
if err := emit(daysCompleted); err != nil {
return &syncFatalError{err}
}
return nil
})
if dayErr != nil {
failures = append(failures, dayErr)
var fatal *syncFatalError
if ctx.Err() != nil || errors.As(dayErr, &fatal) || errors.Is(dayErr, sybclient.ErrSessionInvalid) {
return report, errors.Join(failures...)
}
continue
}
daysCompleted++
if err := emit(daysCompleted); err != nil {
return report, errors.Join(append(failures, err)...)
}
}
report.FinishedAt = time.Now().UTC()
return report, nil
if err := sybproductfilter.UpdateHits(ctx, db, filters, report.filterHits, report.FinishedAt); err != nil {
return report, err
}
return report, errors.Join(failures...)
}
func shopSnapshotNames(allowed map[string]string) []string {
@@ -336,39 +266,7 @@ func moveAcceptedToSkipped(report *SyncReport, listName, detailName string, allo
// `[必须]` The loop bound comes from expectedTotal (listTotal), because
// list.total is the current page's row count, not the filtered total (§4.3).
// Driving the loop with the response's own total would stop after page one.
func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal, maxMatches int) ([]sybclient.StockRow, error) {
if date != shanghaiToday() {
return loadDailyList(ctx, client, date, pageSize, expectedTotal)
}
var last *snapshotDriftError
for attempt := 1; attempt <= maxTodaySnapshotAttempts; attempt++ {
if attempt > 1 {
total, err := client.ListTotal(ctx, date, date, pageSize)
if err != nil {
return nil, fmt.Errorf("重新查询今天 %s 货运单总数失败: %w", date, err)
}
if total > maxMatches {
return nil, fmt.Errorf("今天 %s 的货运单总数 %d 超过单次同步上限 %d", date, total, maxMatches)
}
expectedTotal = total
}
rows, err := loadDailyList(ctx, client, date, pageSize, expectedTotal)
if err == nil {
return rows, nil
}
var drift *snapshotDriftError
if !errors.As(err, &drift) {
return nil, fmt.Errorf("今天第 %d/%d 次拉取失败: %w", attempt, maxTodaySnapshotAttempts, err)
}
last = drift
}
if last != nil && last.valid && len(last.rows) > 0 {
return last.rows, fmt.Errorf("今天持续变化,已保存本次取得的完整明细,但未形成稳定快照;下次同步继续覆盖: %w", last)
}
return nil, fmt.Errorf("今天持续变化,连续 %d 次未形成稳定快照,本次同步停止: %w", maxTodaySnapshotAttempts, last)
}
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int) ([]sybclient.StockRow, error) {
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int, consume ...func([]sybclient.StockRow, int) error) ([]sybclient.StockRow, error) {
rows := make([]sybclient.StockRow, 0, expectedTotal)
type position struct{ page, row int }
seen := make(map[int64]position, expectedTotal)
@@ -387,6 +285,24 @@ func loadDailyList(ctx context.Context, client *sybclient.Client, date string, p
if pageCount != len(page) {
return nil, fmt.Errorf("%s 货运单列表第 %d 页响应条数不自洽:total=%d,list=%d", date, pageIndex, pageCount, len(page))
}
// SYB may change a page while the list is being read. Re-read once
// before treating a boundary-page count drift as a fatal snapshot error.
if len(page) != expectedPageCount {
pause := time.NewTimer(500 * time.Millisecond)
select {
case <-ctx.Done():
pause.Stop()
return nil, ctx.Err()
case <-pause.C:
}
page, pageCount, err = client.ListPage(ctx, date, date, start, pageIndex, pageSize)
if err != nil {
return nil, fmt.Errorf("重读 %s 货运单列表第 %d 页失败:%w", date, pageIndex, err)
}
if pageCount != len(page) {
return nil, fmt.Errorf("%s 货运单列表第 %d 页重读响应条数不自洽:total=%d,list=%d", date, pageIndex, pageCount, len(page))
}
}
// Validate the whole page first: a same-page duplicate must not be
// hidden behind an earlier, recoverable cross-page overlap.
pageSeen := make(map[int64]int, len(page))
@@ -413,6 +329,11 @@ func loadDailyList(ctx context.Context, client *sybclient.Client, date string, p
if len(page) != expectedPageCount {
return nil, &snapshotDriftError{message: fmt.Sprintf("%s 货运单列表第 %d 页不完整且相对初始总数发生变化:预期 %d 行,实际 %d 行", date, pageIndex, expectedPageCount, len(page)), rows: rows, valid: len(page) > expectedPageCount}
}
if len(consume) > 0 {
if err := consume[0](page, pageIndex); err != nil {
return nil, err
}
}
}
// Re-read the total: if it moved while we paged, some order was inserted or
@@ -459,7 +380,11 @@ func validateDetailBatch(requested []int64, details []sybclient.StockDetail) err
// applyStockDetail writes one shipment order's items. Each item is its own
// ApplyDetail call and its own transaction — a several-thousand-row sync must
// not sit in one long-held transaction.
func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, detail sybclient.StockDetail, report *SyncReport) error {
func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, detail sybclient.StockDetail, report *SyncReport, sets ...sybproductfilter.RuleSet) error {
var filters sybproductfilter.RuleSet
if len(sets) > 0 {
filters = sets[0]
}
order := OrderInput{
Code: detail.Code,
StockID: uint64(detail.ID),
@@ -469,6 +394,23 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow,
order.Code = row.Code
}
for _, item := range detail.Details {
variation := stringField(item.Raw, "variationSku")
if rule := filters.Match(variation); rule != nil {
// `[必须]` Count the two kinds separately and per rule. A combined
// total would hide a structural rule that stopped matching because
// 档口 changed its code format, and a per-kind total would make the
// disable-confirmation dialog quote the same figure for "#" and "-"
// even though they match very different numbers of rows (#269).
if rule.Kind == "char" {
report.CharFilterSkipped++
} else {
report.KeywordFilterSkipped++
}
if report.filterHits != nil {
report.filterHits.Add(rule)
}
continue
}
raw, err := json.Marshal(item.Raw)
if err != nil {
return fmt.Errorf("货运单 %s 明细 %d 原始数据编码失败: %w", order.Code, item.ID, err)
+88
View File
@@ -0,0 +1,88 @@
package sybimport
import (
"context"
"go-admin/app/goauto/sybclient"
"go-admin/app/goauto/sybproductfilter"
"go-admin/app/goauto/sybshop"
"gorm.io/gorm"
)
// Database/progress errors stop the entire run, not merely the current date.
type syncFatalError struct{ error }
func (e *syncFatalError) Unwrap() error { return e.error }
// Read all remote details before opening a page transaction. Publish counters
// only after commit, so a rollback never reports records as saved.
func importSyncPage(ctx context.Context, db *gorm.DB, client *sybclient.Client, rows []sybclient.StockRow, allowed map[string]string, report *SyncReport, sets ...sybproductfilter.RuleSet) error {
var filters sybproductfilter.RuleSet
if len(sets) > 0 {
filters = sets[0]
}
next := *report
next.ShopBreakdown = make(map[string]ShopBreakdown, len(report.ShopBreakdown))
for k, v := range report.ShopBreakdown {
next.ShopBreakdown[k] = v
}
byID := make(map[int64]sybclient.StockRow)
var ids []int64
for _, row := range rows {
name := stringField(row.Raw, "shopName")
label := name
if sybshop.IsBlank(label) {
label = "(无店铺名)"
}
display, ok := allowed[sybshop.Normalize(name)]
if ok {
label = display
}
entry := next.ShopBreakdown[label]
if ok {
entry.Accepted++
next.AcceptedCount++
ids = append(ids, row.ID)
byID[row.ID] = row
} else {
entry.Skipped++
next.ShopSkipped++
}
next.ShopBreakdown[label] = entry
}
var details []sybclient.StockDetail
for start := 0; start < len(ids); start += detailBatch {
end := start + detailBatch
if end > len(ids) {
end = len(ids)
}
batch, err := client.DetailListByStock(ctx, ids[start:end])
if err != nil {
return err
}
if err := validateDetailBatch(ids[start:end], batch); err != nil {
return err
}
details = append(details, batch...)
}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, detail := range details {
name := stringField(detail.Raw, "shopName")
_, ok := allowed[sybshop.Normalize(name)]
if sybshop.IsBlank(name) || !ok {
moveAcceptedToSkipped(&next, stringField(byID[detail.ID].Raw, "shopName"), name, allowed)
next.AcceptedCount--
next.ShopSkipped++
continue
}
if err := applyStockDetail(ctx, tx, byID[detail.ID], detail, &next, filters); err != nil {
return err
}
}
return nil
})
if err != nil {
return &syncFatalError{err}
}
*report = next
return nil
}
@@ -0,0 +1,117 @@
package sybimport
import (
"context"
"errors"
"go-admin/app/goauto/models"
"strings"
"testing"
)
func TestPageFailurePreservesEarlierPagesContinuesDatesAndRerunsIdempotently(t *testing.T) {
db := newSyncTestDB(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}, shortPageAtIndex: 2}
var last SyncProgress
report, err := SyncWithProgress(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02", func(p SyncProgress) error { last = p; return nil })
if err == nil || !strings.Contains(err.Error(), "第 2 页") || report.Created != 2 || report.Updated != 1 || last.DaysProcessed != 1 {
t.Fatalf("report=%+v progress=%+v err=%v", report, last, err)
}
var count int64
db.Model(&models.SYBProduct{}).Count(&count)
if count != 2 {
t.Fatal(count)
}
f.shortPageAtIndex = 0
report, err = Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02")
if err != nil || report.Created != 1 || report.Updated != 3 {
t.Fatalf("rerun=%+v err=%v", report, err)
}
db.Model(&models.SYBProduct{}).Count(&count)
if count != 3 {
t.Fatal(count)
}
}
func TestPageTransactionRollbackDoesNotPublishCountersAndStopsDates(t *testing.T) {
db := newSyncTestDB(t)
if err := db.Exec("CREATE TRIGGER reject_second BEFORE INSERT ON syb_product WHEN NEW.detail_id = 10011 BEGIN SELECT RAISE(ABORT, 'test database failure'); END").Error; err != nil {
t.Fatal(err)
}
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}}
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02")
var fatal *syncFatalError
if !errors.As(err, &fatal) || report.Created != 0 || report.DetailCount != 0 || len(f.listCalls) != 1 {
t.Fatalf("report=%+v calls=%v err=%v", report, f.listCalls, err)
}
var count int64
db.Model(&models.SYBProduct{}).Count(&count)
if count != 0 {
t.Fatal(count)
}
}
func TestPageProgressFailureStopsAfterCommittedPage(t *testing.T) {
db := newSyncTestDB(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}}
calls := 0
report, err := SyncWithProgress(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02", func(SyncProgress) error {
calls++
if calls == 2 {
return errors.New("progress unavailable")
}
return nil
})
if err == nil || report.Created != 2 || len(f.listCalls) != 1 {
t.Fatalf("report=%+v err=%v", report, err)
}
}
func TestPageMissingDetailPreservesPriorPageAndContinuesNextDate(t *testing.T) {
db := newSyncTestDB(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}, detailDropID: 1002}
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02")
if err == nil || report.Created != 2 || report.Updated != 1 || report.OrderCount != 4 || len(f.listCalls) != 3 {
t.Fatalf("report=%+v err=%v", report, err)
}
}
func TestPageCancellationPreservesCommitAndStopsNextPage(t *testing.T) {
db := newSyncTestDB(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
report, err := SyncWithProgress(ctx, db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02", func(p SyncProgress) error {
if p.Report.Created > 0 {
cancel()
}
return nil
})
if !errors.Is(err, context.Canceled) || report.Created != 2 || len(f.listCalls) != 1 {
t.Fatalf("report=%+v err=%v", report, err)
}
}
func TestPartialFinishPreservesProgressReleasesSlotAndIsFilterable(t *testing.T) {
db := newSyncTestDB(t)
ctx := context.Background()
s := NewSyncRunService(db)
run, err := s.Create(ctx, CreateSyncRunInput{DateFrom: "2026-08-01", DateTo: "2026-08-02"})
if err != nil {
t.Fatal(err)
}
report := SyncReport{Created: 2, DetailCount: 2, ShopBreakdown: map[string]ShopBreakdown{}}
if err := s.UpdateProgress(ctx, run.ID, SyncProgress{Report: report, DaysTotal: 2, DaysProcessed: 1}); err != nil {
t.Fatal(err)
}
if err := s.Finish(ctx, run.ID, SyncRunPartial, report, errors.New("第 2 页失败")); err != nil {
t.Fatal(err)
}
row, err := s.Detail(ctx, run.ID)
if err != nil || row.Status != SyncRunPartial || row.ActiveSlot != nil || row.ProgressPercent != 50 || row.Created != 2 {
t.Fatalf("row=%+v err=%v", row, err)
}
result, err := s.List(ctx, SyncRunListRequest{Page: 1, PageSize: 20, Status: SyncRunPartial})
if err != nil || result.Total != 1 {
t.Fatalf("result=%+v err=%v", result, err)
}
}
+4 -1
View File
@@ -18,6 +18,7 @@ import (
const (
SyncRunRunning = "running"
SyncRunSucceeded = "succeeded"
SyncRunPartial = "partial_success"
SyncRunFailed = "failed"
SyncRunInterrupted = "interrupted"
)
@@ -69,13 +70,14 @@ func (s *SyncRunService) UpdateProgress(ctx context.Context, id uint64, progress
"days_total": progress.DaysTotal, "days_processed": progress.DaysProcessed,
"order_count": progress.Report.OrderCount, "detail_count": progress.Report.DetailCount,
"accepted_count": progress.Report.AcceptedCount, "shop_skipped": progress.Report.ShopSkipped,
"char_filter_skipped": progress.Report.CharFilterSkipped, "keyword_filter_skipped": progress.Report.KeywordFilterSkipped,
"created_count": progress.Report.Created, "updated_count": progress.Report.Updated,
"shop_filter_hash": progress.Report.ShopFilterHash, "shop_breakdown_json": payload,
}).Error
}
func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, report SyncReport, runErr error) error {
if status != SyncRunSucceeded && status != SyncRunFailed {
if status != SyncRunSucceeded && status != SyncRunPartial && status != SyncRunFailed {
return fmt.Errorf("invalid terminal sync status %q", status)
}
payload, err := marshalSyncRunPayload(report.ShopBreakdown, report.ShopFilterSnapshot)
@@ -91,6 +93,7 @@ func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, r
"status": status, "active_slot": nil, "finished_at": now, "error_message": message,
"order_count": report.OrderCount, "detail_count": report.DetailCount,
"accepted_count": report.AcceptedCount, "shop_skipped": report.ShopSkipped,
"char_filter_skipped": report.CharFilterSkipped, "keyword_filter_skipped": report.KeywordFilterSkipped,
"created_count": report.Created, "updated_count": report.Updated,
"shop_breakdown_json": payload,
}
@@ -20,7 +20,7 @@ func (handler Handler) ListSyncRuns(c *gin.Context) {
return
}
status := strings.TrimSpace(c.Query("status"))
if status != "" && status != SyncRunRunning && status != SyncRunSucceeded && status != SyncRunFailed && status != SyncRunInterrupted {
if status != "" && status != SyncRunRunning && status != SyncRunSucceeded && status != SyncRunPartial && status != SyncRunFailed && status != SyncRunInterrupted {
writeError(c, invalidRequest("status 无效"))
return
}
@@ -36,4 +36,12 @@ func TestSyncRunListAndDetailHandlers(t *testing.T) {
if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), `"operatorName":"管理员"`) {
t.Fatalf("详情响应不正确: %d %s", detail.Code, detail.Body.String())
}
if err := NewSyncRunService(db).Finish(context.Background(), run.ID, SyncRunPartial, SyncReport{Created: 1}, nil); err != nil {
t.Fatal(err)
}
partial := httptest.NewRecorder()
engine.ServeHTTP(partial, httptest.NewRequest(http.MethodGet, "/sync-runs?status=partial_success", nil))
if partial.Code != http.StatusOK || !strings.Contains(partial.Body.String(), `"total":1`) {
t.Fatalf("partial filter: %d %s", partial.Code, partial.Body.String())
}
}
-34
View File
@@ -9,7 +9,6 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
@@ -276,39 +275,6 @@ func TestSyncStopsWhenTotalDriftsDuringPaging(t *testing.T) {
}
}
func TestTodaySnapshotDriftRetriesFromFirstPage(t *testing.T) {
originalNow := syncNow
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
t.Cleanup(func() { syncNow = originalNow })
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 10}, totalOverride: map[int]int{1: 11, 2: 10, 3: 10}}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 10, 10, 1000)
if err != nil || len(rows) != 10 || f.listTotalCalls != 3 {
t.Fatalf("rows=%d totalCalls=%d err=%v", len(rows), f.listTotalCalls, err)
}
}
func TestTodayThirdDriftReturnsValidLastListForDegradedSave(t *testing.T) {
originalNow := syncNow
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
t.Cleanup(func() { syncNow = originalNow })
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 10}, totalOverride: map[int]int{1: 11, 2: 10, 3: 11, 4: 10, 5: 11}}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 10, 10, 1000)
if err == nil || len(rows) != 10 || !strings.Contains(err.Error(), "已保存本次取得的完整明细") {
t.Fatalf("rows=%d err=%v", len(rows), err)
}
}
func TestHistoricalSnapshotDriftDoesNotRetry(t *testing.T) {
originalNow := syncNow
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
t.Cleanup(func() { syncNow = originalNow })
f := &fakeSYB{perDay: map[string]int{"2026-08-28": 10}, totalOverride: map[int]int{1: 11}}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-28", 10, 10, 1000)
if err == nil || rows != nil || f.listTotalCalls != 1 {
t.Fatalf("rows=%v totalCalls=%d err=%v", rows, f.listTotalCalls, err)
}
}
// 某页行数少于预期同样是不完整快照。
func TestSyncStopsOnShortPage(t *testing.T) {
db := newSyncTestDB(t)
@@ -0,0 +1,129 @@
package sybproductfilter
import (
"encoding/json"
"errors"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"gorm.io/gorm"
"net/http"
"strconv"
)
type Handler struct{ DB *gorm.DB }
func (h Handler) service(c *gin.Context) (*Service, bool) {
db := h.DB
var e error
if db == nil {
db, e = pkg.GetOrm(c)
}
if e != nil {
writeError(c, internal(e))
return nil, false
}
return NewService(db), true
}
func (h Handler) List(c *gin.Context) {
s, ok := h.service(c)
if !ok {
return
}
p, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
ps, _ := strconv.Atoi(c.DefaultQuery("pageSize", "50"))
r, e := s.List(c.Request.Context(), ListRequest{Page: p, PageSize: ps, Kind: c.Query("kind"), Keyword: c.Query("keyword"), EnabledOnly: c.Query("enabledOnly") == "true"})
if e != nil {
writeError(c, e)
return
}
c.JSON(200, gin.H{"code": 200, "data": r})
}
func (h Handler) Create(c *gin.Context) {
var r CreateRequest
if !decode(c, &r) {
return
}
s, ok := h.service(c)
if !ok {
return
}
x, e := s.Create(c.Request.Context(), r)
if e != nil {
writeError(c, e)
return
}
c.JSON(200, gin.H{"code": 200, "data": x})
}
func (h Handler) SetEnabled(c *gin.Context) {
id, ok := idParam(c)
if !ok {
return
}
var r SetEnabledRequest
if !decode(c, &r) {
return
}
s, ok := h.service(c)
if !ok {
return
}
claims := jwt.ExtractClaims(c)
name, _ := claims["nice"].(string)
x, e := s.SetEnabled(c.Request.Context(), id, r, name)
if e != nil {
writeError(c, e)
return
}
c.JSON(200, gin.H{"code": 200, "data": x})
}
func (h Handler) Delete(c *gin.Context) {
id, ok := idParam(c)
if !ok {
return
}
s, ok := h.service(c)
if !ok {
return
}
if e := s.Delete(c.Request.Context(), id); e != nil {
writeError(c, e)
return
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"deleted": true}})
}
func idParam(c *gin.Context) (uint64, bool) {
id, e := strconv.ParseUint(c.Param("filterId"), 10, 64)
if e != nil || id == 0 {
writeError(c, invalid("过滤规则 id 必须是正整数"))
return 0, false
}
return id, true
}
func decode(c *gin.Context, t any) bool {
d := json.NewDecoder(c.Request.Body)
d.DisallowUnknownFields()
if e := d.Decode(t); e != nil {
writeError(c, invalid("请求体解析失败:"+e.Error()))
return false
}
return true
}
func writeError(c *gin.Context, e error) {
var se *ServiceError
if !errors.As(e, &se) {
se = internal(e).(*ServiceError)
}
status := 500
switch se.Code {
case CodeInvalidRequest:
status = 422
case CodeConflict:
status = 409
case CodeNotFound:
status = 404
case CodeForbidden:
status = http.StatusForbidden
}
c.JSON(status, gin.H{"code": se.Code, "message": se.Message})
}
@@ -0,0 +1,84 @@
package sybproductfilter
import (
"context"
"testing"
"time"
)
// 用线上真实取到的 variationSku 样本,验证过滤判定与每条规则各自计数。
func TestRealWorldSamplesPerRuleCounting(t *testing.T) {
db := testDB(t)
ctx := context.Background()
set, err := LoadEnabled(ctx, db)
if err != nil {
t.Fatal(err)
}
samples := []struct {
v string
wantKind string // "" = 不过滤
}{
{"新寮-雅伊阁大码女装#301", "char"},
{"广达-爆款大码女装#9018", "char"},
{"DD#004", "char"},
{"300斤牛奶絲圓領#A057", "char"},
{"上寮-琪琪尚雅网批#2604##(&15)", "char"},
{"PDD0582", ""},
{"my238", ""},
{"le171", ""},
{"1", ""},
{"引流款", ""},
{"", ""},
}
hits := HitCounter{}
var charN, kwN, keptN int
for _, s := range samples {
rule := set.Match(s.v)
if s.wantKind == "" {
if rule != nil {
t.Fatalf("%q 不应被过滤,却命中了 %q", s.v, rule.Keyword)
}
keptN++
continue
}
if rule == nil || rule.Kind != s.wantKind {
t.Fatalf("%q 应命中 %s 规则,实际 %v", s.v, s.wantKind, rule)
}
hits.Add(rule)
if rule.Kind == "char" {
charN++
} else {
kwN++
}
}
if charN != 5 || kwN != 0 || keptN != 6 {
t.Fatalf("统计不符:char=%d keyword=%d kept=%d", charN, kwN, keptN)
}
if err := UpdateHits(ctx, db, set, hits, time.Now().UTC()); err != nil {
t.Fatal(err)
}
after, err := LoadEnabled(ctx, db)
if err != nil {
t.Fatal(err)
}
got := map[string]int{}
for _, x := range after.Chars {
if x.LastHitCount == nil {
t.Fatalf("规则 %q 必须被写入命中数", x.Keyword)
}
got[x.Keyword] = *x.LastHitCount
}
// "#" 命中 4 条(301/9018/DD#004/A057 ... 其中带 - 的先被 "-" 规则接走)
if got["-"]+got["#"] != 5 {
t.Fatalf("两条结构规则命中数之和应为 5,实际 - =%d # =%d", got["-"], got["#"])
}
if got["-"] == got["#"] {
t.Fatalf("两条规则不应记成同一个数(这正是 #269 修的 bug): - =%d # =%d", got["-"], got["#"])
}
for _, x := range after.Keywords {
if x.LastHitCount == nil || *x.LastHitCount != 0 {
t.Fatalf("未命中的关键词 %q 应记 0,实际 %v", x.Keyword, x.LastHitCount)
}
}
t.Logf("结构规则命中:- =%d # =%d", got["-"], got["#"])
}
@@ -0,0 +1,15 @@
package sybproductfilter
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/common/middleware"
)
func InitRouter(e *gin.Engine, a *jwt.GinJWTMiddleware) {
g := e.Group("/api/admin/v1/syb-product-filters").Use(a.MiddlewareFunc()).Use(middleware.AuthCheckRole())
g.GET("", Handler{}.List)
g.POST("", middleware.RequireRoleKey("admin"), Handler{}.Create)
g.PATCH("/:filterId/enabled", middleware.RequireRoleKey("admin"), Handler{}.SetEnabled)
g.DELETE("/:filterId", middleware.RequireRoleKey("admin"), Handler{}.Delete)
}
@@ -0,0 +1,252 @@
package sybproductfilter
import (
"context"
"errors"
"fmt"
"strings"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybshop"
"gorm.io/gorm"
)
type ServiceError struct {
Code, Message string
Cause error
}
func (e *ServiceError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
}
return e.Message
}
func (e *ServiceError) Unwrap() error { return e.Cause }
const (
CodeInvalidRequest = "INVALID_REQUEST"
CodeNotFound = "SYB_PRODUCT_FILTER_NOT_FOUND"
CodeConflict = "SYB_PRODUCT_FILTER_DUPLICATE"
CodeForbidden = "FORBIDDEN"
)
func invalid(m string) error { return &ServiceError{Code: CodeInvalidRequest, Message: m} }
func internal(e error) error {
return &ServiceError{Code: "INTERNAL_ERROR", Message: "服务器内部错误", Cause: e}
}
type Service struct{ DB *gorm.DB }
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
type ListRequest struct {
Page, PageSize int
Kind, Keyword string
EnabledOnly bool
}
type ListResponse struct {
Items []models.SYBProductFilter `json:"items"`
Total int64 `json:"total"`
EnabledCount int64 `json:"enabledCount"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
func (s *Service) List(ctx context.Context, r ListRequest) (ListResponse, error) {
if r.Page < 1 {
r.Page = 1
}
if r.PageSize < 1 || r.PageSize > 200 {
r.PageSize = 50
}
q := s.DB.WithContext(ctx).Model(&models.SYBProductFilter{})
if r.Kind != "" {
q = q.Where("kind = ?", r.Kind)
}
if k := sybshop.Normalize(r.Keyword); k != "" {
q = q.Where("normalized_keyword LIKE ?", "%"+k+"%")
}
if r.EnabledOnly {
q = q.Where("enabled = ?", true)
}
var total, enabled int64
if err := q.Count(&total).Error; err != nil {
return ListResponse{}, internal(err)
}
items := make([]models.SYBProductFilter, 0, r.PageSize)
if err := q.Order("kind ASC, id ASC").Offset((r.Page - 1) * r.PageSize).Limit(r.PageSize).Find(&items).Error; err != nil {
return ListResponse{}, internal(err)
}
if err := s.DB.WithContext(ctx).Model(&models.SYBProductFilter{}).Where("enabled = ?", true).Count(&enabled).Error; err != nil {
return ListResponse{}, internal(err)
}
return ListResponse{Items: items, Total: total, EnabledCount: enabled, Page: r.Page, PageSize: r.PageSize}, nil
}
type CreateRequest struct {
Kind string `json:"kind"`
Keyword string `json:"keyword"`
Enabled *bool `json:"enabled"`
Note *string `json:"note"`
}
func validate(kind, keyword string) (string, string, error) {
if kind != "char" && kind != "keyword" {
return "", "", invalid("kind 必须是 char 或 keyword")
}
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return "", "", invalid("关键词不能为空")
}
if sybshop.ContainsControl(keyword) {
return "", "", invalid("关键词不能包含控制字符")
}
return keyword, sybshop.Normalize(keyword), nil
}
func (s *Service) Create(ctx context.Context, r CreateRequest) (models.SYBProductFilter, error) {
if r.Kind == "char" {
return models.SYBProductFilter{}, &ServiceError{Code: CodeForbidden, Message: "结构过滤不可新增"}
}
k, n, e := validate(r.Kind, r.Keyword)
if e != nil {
return models.SYBProductFilter{}, e
}
var x models.SYBProductFilter
if err := s.DB.WithContext(ctx).Where("kind = ? AND normalized_keyword = ?", r.Kind, n).First(&x).Error; err == nil {
return models.SYBProductFilter{}, &ServiceError{Code: CodeConflict, Message: fmt.Sprintf("该关键词已存在(归一化后与「%s」相同)", x.Keyword)}
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return models.SYBProductFilter{}, internal(err)
}
en := true
if r.Enabled != nil {
en = *r.Enabled
}
x = models.SYBProductFilter{Kind: r.Kind, Keyword: k, NormalizedKeyword: n, Enabled: en, Note: r.Note}
if err := s.DB.WithContext(ctx).Create(&x).Error; err != nil {
return models.SYBProductFilter{}, internal(err)
}
return x, nil
}
type SetEnabledRequest struct {
Enabled bool `json:"enabled"`
}
func (s *Service) SetEnabled(ctx context.Context, id uint64, r SetEnabledRequest, operator string) (models.SYBProductFilter, error) {
var x models.SYBProductFilter
if err := s.DB.WithContext(ctx).First(&x, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return x, &ServiceError{Code: CodeNotFound, Message: "过滤规则不存在"}
}
return x, internal(err)
}
now := time.Now().UTC()
updates := map[string]any{"enabled": r.Enabled, "updated_at": now}
if x.Kind == "char" {
updates["last_changed_by"] = strings.TrimSpace(operator)
updates["last_changed_at"] = now
}
if err := s.DB.WithContext(ctx).Model(&x).Updates(updates).Error; err != nil {
return x, internal(err)
}
x.Enabled = r.Enabled
x.UpdatedAt = now
if x.Kind == "char" {
x.LastChangedBy = strings.TrimSpace(operator)
x.LastChangedAt = &now
}
return x, nil
}
func (s *Service) Delete(ctx context.Context, id uint64) error {
return s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var x models.SYBProductFilter
if err := tx.First(&x, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &ServiceError{Code: CodeNotFound, Message: "过滤规则不存在"}
}
return internal(err)
}
if x.Kind == "char" {
return &ServiceError{Code: CodeForbidden, Message: "结构过滤不可删除"}
}
return tx.Model(&x).Updates(map[string]any{"deleted_flag": x.ID, "deleted_at": time.Now().UTC()}).Error
})
}
type RuleSet struct{ Chars, Keywords []models.SYBProductFilter }
func LoadEnabled(ctx context.Context, db *gorm.DB) (RuleSet, error) {
var rows []models.SYBProductFilter
if err := db.WithContext(ctx).Where("enabled = ?", true).Find(&rows).Error; err != nil {
return RuleSet{}, err
}
r := RuleSet{}
for _, x := range rows {
if x.Kind == "char" {
r.Chars = append(r.Chars, x)
} else {
r.Keywords = append(r.Keywords, x)
}
}
return r, nil
}
// Match reports which enabled rule first matched v, or nil when none did.
//
// It returns the matching rule rather than a pair of booleans because the
// caller has to attribute the hit to one specific rule: every rule keeps its
// own LastHitCount, and the disable-confirmation dialog quotes that per-rule
// number ("停用「#」会让约 N 条恢复入库"). Collapsing the result to
// char/keyword booleans would force every rule of a kind to share one
// aggregate, and the dialog would then warn with the wrong figure — on real
// data "#" matches 8498 rows and "-" matches 6392, so the two must never be
// reported as the same number.
//
// Structural rules are checked before keyword rules so a hit is attributed the
// way the page presents the two sections, and an empty variationSku never
// matches: it is a product with no supplier code yet, not a 档口 item.
func (r RuleSet) Match(v string) *models.SYBProductFilter {
if v == "" {
return nil
}
n := sybshop.Normalize(v)
for i, x := range r.Chars {
if strings.Contains(n, x.NormalizedKeyword) {
return &r.Chars[i]
}
}
for i, x := range r.Keywords {
if strings.Contains(n, x.NormalizedKeyword) {
return &r.Keywords[i]
}
}
return nil
}
// HitCounter accumulates per-rule hits over one sync run, keyed by rule id.
type HitCounter map[uint64]int
// Add records one hit against the rule that matched.
func (h HitCounter) Add(rule *models.SYBProductFilter) {
if rule == nil {
return
}
h[rule.ID]++
}
// UpdateHits writes each rule's own hit count back to its row.
//
// Every rule in the set is written, including those with zero hits: leaving a
// previous run's number in place would make the confirmation dialog quote a
// figure that no longer describes the current data.
func UpdateHits(ctx context.Context, db *gorm.DB, r RuleSet, hits HitCounter, at time.Time) error {
for _, x := range append(append([]models.SYBProductFilter{}, r.Chars...), r.Keywords...) {
if err := db.WithContext(ctx).Model(&models.SYBProductFilter{}).Where("id = ?", x.ID).
Updates(map[string]any{"last_hit_count": hits[x.ID], "last_hit_at": at}).Error; err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,127 @@
package sybproductfilter
import (
"context"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"testing"
"time"
)
func testDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = migrations.Migrate(db); err != nil {
t.Fatal(err)
}
return db
}
func TestMatchORAndNormalization(t *testing.T) {
r := RuleSet{
Chars: []models.SYBProductFilter{{ID: 1, Kind: "char", NormalizedKeyword: "-"}, {ID: 2, Kind: "char", NormalizedKeyword: "#"}},
Keywords: []models.SYBProductFilter{{ID: 3, Kind: "keyword", NormalizedKeyword: "cvc短袖220斤"}},
}
// `[必须]` OR, never AND. Both of these contain "#" but no "-", and on real
// production data that difference is 2118 rows (#269).
for _, v := range []string{"DD#004", "300斤牛奶絲圓領#A057"} {
hit := r.Match(v)
if hit == nil || hit.Kind != "char" {
t.Fatalf("%q should match a structural rule, got %v", v, hit)
}
if hit.ID != 2 {
t.Fatalf("%q should be attributed to the \"#\" rule, got id %d", v, hit.ID)
}
}
if hit := r.Match("新寮-雅伊阁大码女装"); hit == nil || hit.ID != 1 {
t.Fatalf("dash-only value should be attributed to the \"-\" rule, got %v", hit)
}
if hit := r.Match(""); hit != nil {
t.Fatal("empty variationSku must not match: it is a product without a supplier code")
}
if hit := r.Match("PDD0582"); hit != nil {
t.Fatalf("own product code must not match, got %v", hit)
}
if hit := r.Match(" CVC短袖220斤 "); hit == nil || hit.Kind != "keyword" {
t.Fatalf("full-width and surrounding spaces should normalize to a keyword hit, got %v", hit)
}
if hit := r.Match("cvc短袖220斤"); hit == nil || hit.Kind != "keyword" {
t.Fatalf("lowercase should normalize to a keyword hit, got %v", hit)
}
if hit := r.Match("纯棉"); hit != nil {
t.Fatal("normalization must not convert traditional to simplified Chinese")
}
}
// TestUpdateHitsIsPerRule pins the rule that每条规则各记各的命中数. Writing one
// aggregate per kind would make the disable-confirmation dialog quote the same
// figure for "#" and "-" even though they match very different numbers of rows.
func TestUpdateHitsIsPerRule(t *testing.T) {
db := testDB(t)
ctx := context.Background()
set, err := LoadEnabled(ctx, db)
if err != nil {
t.Fatal(err)
}
if len(set.Chars) != 2 {
t.Fatalf("migration should seed 2 structural rules, got %d", len(set.Chars))
}
byKeyword := map[string]uint64{}
for _, x := range set.Chars {
byKeyword[x.Keyword] = x.ID
}
hits := HitCounter{}
for i := 0; i < 8498; i++ {
hits[byKeyword["#"]]++
}
for i := 0; i < 6392; i++ {
hits[byKeyword["-"]]++
}
if err := UpdateHits(ctx, db, set, hits, time.Now().UTC()); err != nil {
t.Fatal(err)
}
var rows []models.SYBProductFilter
if err := db.Find(&rows).Error; err != nil {
t.Fatal(err)
}
want := map[string]int{"#": 8498, "-": 6392}
for _, x := range rows {
if x.LastHitCount == nil {
t.Fatalf("rule %q must be written even when it matched nothing", x.Keyword)
}
if w, ok := want[x.Keyword]; ok {
if *x.LastHitCount != w {
t.Fatalf("rule %q should record its own %d hits, got %d", x.Keyword, w, *x.LastHitCount)
}
continue
}
if *x.LastHitCount != 0 {
t.Fatalf("keyword rule %q matched nothing and must record 0, got %d", x.Keyword, *x.LastHitCount)
}
if x.LastHitAt == nil {
t.Fatalf("rule %q must still be stamped so a stale count is never left behind", x.Keyword)
}
}
}
func TestCharCreateDeleteRejectedAndKeywordDuplicate(t *testing.T) {
db := testDB(t)
s := NewService(db)
ctx := context.Background()
if _, e := s.Create(ctx, CreateRequest{Kind: "char", Keyword: "@"}); e == nil {
t.Fatal("char create must be rejected")
}
if e := s.Delete(ctx, 1); e == nil {
t.Fatal("seed char delete must be rejected")
}
if _, e := s.Create(ctx, CreateRequest{Kind: "keyword", Keyword: " CVC "}); e != nil {
t.Fatal(e)
}
if _, e := s.Create(ctx, CreateRequest{Kind: "keyword", Keyword: "cvc"}); e == nil {
t.Fatal("normalized duplicate must be rejected")
}
}
+19
View File
@@ -78,6 +78,25 @@ func (handler Handler) AdminBatchCreate(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
}
func (handler Handler) AdminBatchCreateImageSearch(c *gin.Context) {
var request ImageSearchBatchRequest
if err := decodeStrict(c, &request); err != nil {
writeError(c, serviceError("INVALID_REQUEST", "请求 JSON 无效"))
return
}
service, _, ok := handler.service(c)
if !ok {
return
}
response, err := service.BatchCreateImageSearch(c.Request.Context(), request)
if err != nil {
writeError(c, err)
return
}
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
}
func (handler Handler) AdminDetail(c *gin.Context) {
id, err := taskID(c)
if err != nil || id == 0 {
+5 -3
View File
@@ -211,7 +211,7 @@ func (service *Service) IdentifyCurrentPage(ctx context.Context, taskID uint64,
}
return CurrentPageIdentifyResponse{}, internalError(err)
}
if existing.Source != models.CollectionTaskSourceAgentCurrentPage || existing.DeviceID == nil || *existing.DeviceID != deviceRecord.ID {
if (existing.Source != models.CollectionTaskSourceAgentCurrentPage && existing.Source != models.CollectionTaskSourceImageSearch) || existing.DeviceID == nil || *existing.DeviceID != deviceRecord.ID {
return CurrentPageIdentifyResponse{}, serviceError(CodeTaskNotFound, "任务不存在")
}
if existing.IdentifyRequestID != nil && *existing.IdentifyRequestID == request.RequestID && existing.PDDProductID != nil {
@@ -240,7 +240,7 @@ func (service *Service) IdentifyCurrentPage(ctx context.Context, taskID uint64,
}
return internalError(err)
}
if record.Source != models.CollectionTaskSourceAgentCurrentPage || record.DeviceID == nil || *record.DeviceID != deviceRecord.ID {
if (record.Source != models.CollectionTaskSourceAgentCurrentPage && record.Source != models.CollectionTaskSourceImageSearch) || record.DeviceID == nil || *record.DeviceID != deviceRecord.ID {
return serviceError(CodeTaskNotFound, "任务不存在")
}
if record.IdentifyRequestID != nil && *record.IdentifyRequestID == request.RequestID && record.PDDProductID != nil {
@@ -255,7 +255,9 @@ func (service *Service) IdentifyCurrentPage(ctx context.Context, taskID uint64,
return serviceError(CodeCurrentPageIdentityConflict, "当前商品与任务已识别商品不一致")
}
now := service.Now()
if err := tx.Model(&models.CollectionTask{}).Where("id = ?", record.ID).Updates(map[string]any{
// Validate the locked task's real state; an empty model fails BeforeSave
// when a retried current-page task already has a product binding.
if err := tx.Model(&record).Where("id = ?", record.ID).Updates(map[string]any{
"identify_request_id": request.RequestID, "identity_resolved_at": now,
}).Error; err != nil {
return internalError(err)
@@ -0,0 +1,153 @@
package task
import (
"context"
"reflect"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/rulecontract"
"github.com/google/uuid"
)
func TestCurrentPageReidentifyAfterSameTaskRetry(t *testing.T) {
db := openTaskDatabase(t)
_, token := registerTaskDeviceWithCapabilities(t, db, "retry-device", []string{
rulecontract.CapabilitySchemaV2,
rulecontract.CapabilityPDDProductDetailV1,
rulecontract.CapabilityPDDCurrentPageShareV1,
})
_, otherToken := registerTaskDevice(t, db, "other-device")
rule := models.CollectionRule{Name: "retry-rule", ContentJSON: v2TaskRuleSnapshot()}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.AgentManualCollectionSetting{ID: 1, RuleID: rule.ID}).Error; err != nil {
t.Fatal(err)
}
service := newTaskService(db)
ctx := context.Background()
created, err := service.CreateCurrentPage(ctx, CurrentPageCreateRequest{RequestID: uuid.NewString()}, token)
if err != nil {
t.Fatal(err)
}
load := func() models.CollectionTask {
t.Helper()
var record models.CollectionTask
if err := db.First(&record, created.TaskID).Error; err != nil {
t.Fatal(err)
}
return record
}
request := CurrentPageIdentifyRequest{
RequestID: uuid.NewString(), ShareURL: "https://mobile.yangkeduo.com/goods.html?goods_id=700000000255",
}
// Direct links resolve locally: the regression never calls PDD or a live DB.
identity, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token)
if err != nil {
t.Fatal(err)
}
const failureCode = "SPEC_PANEL_EVIDENCE_NOT_MATCHED"
if _, err := service.SubmitFailure(ctx, created.TaskID, FailRequest{
RequestID: uuid.NewString(), Code: failureCode, Message: "Synthetic panel failure",
}, token); err != nil {
t.Fatal(err)
}
reset, err := service.ResetForDevice(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token)
if err != nil || reset.TaskID != created.TaskID || reset.AttemptNumber != 2 || reset.Status != models.TaskStatusPending {
t.Fatalf("same-task reset: %+v, %v", reset, err)
}
resetRecord := load()
if resetRecord.PDDProductID == nil || *resetRecord.PDDProductID != identity.PDDProductID ||
resetRecord.IdentifyRequestID != nil || resetRecord.IdentityResolvedAt != nil {
t.Fatal("reset must keep the product and clear per-attempt identification")
}
if _, err := service.Claim(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil {
t.Fatal(err)
}
if _, err := service.Start(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil {
t.Fatal(err)
}
before := load()
request.RequestID = uuid.NewString()
for _, tc := range []struct {
name, token, url, code string
}{
{"other device", otherToken, request.ShareURL, CodeTaskNotFound},
{"other product", token, "https://mobile.yangkeduo.com/goods.html?goods_id=700000000256", CodeCurrentPageIdentityConflict},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := service.IdentifyCurrentPage(ctx, created.TaskID, CurrentPageIdentifyRequest{RequestID: uuid.NewString(), ShareURL: tc.url}, tc.token)
if taskErrorCode(t, err) != tc.code {
t.Fatalf("expected %s, got %v", tc.code, err)
}
if !reflect.DeepEqual(before, load()) {
t.Fatal("rejected identification changed the task")
}
})
}
reidentified, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token)
if err != nil {
t.Fatalf("reidentify same product after retry: %v", err)
}
if reidentified.TaskID != created.TaskID || reidentified.PDDProductID != identity.PDDProductID ||
reidentified.GoodsID != identity.GoodsID || reidentified.URL != identity.URL || !reidentified.Replayed {
t.Fatalf("identity changed: %+v", reidentified)
}
after := load()
if after.IdentifyRequestID == nil || *after.IdentifyRequestID != request.RequestID || after.IdentityResolvedAt == nil {
t.Fatal("new attempt identification was not persisted")
}
// Apart from identity metadata and the normal update timestamp, every field
// (including status, device, rule, lease and uniqueness slots) must survive.
expected := before
expected.IdentifyRequestID = after.IdentifyRequestID
expected.IdentityResolvedAt = after.IdentityResolvedAt
expected.UpdatedAt = after.UpdatedAt
if !reflect.DeepEqual(expected, after) {
t.Fatal("reidentification changed unrelated task fields")
}
service.ResolveCurrentPageShare = func(context.Context, string) (ResolvedPDDShare, error) {
t.Fatal("replay must not resolve the link again")
return ResolvedPDDShare{}, nil
}
replay, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token)
if err != nil || replay != reidentified || !reflect.DeepEqual(after, load()) {
t.Fatalf("replay changed identity or task: %+v, %v", replay, err)
}
service.ResolveCurrentPageShare = nil
title := "Synthetic collected product"
result, err := service.SubmitResult(ctx, created.TaskID, ResultRequest{
RequestID: uuid.NewString(), Status: models.TaskStatusCompleted,
Product: ResultProduct{PDDGoodsID: identity.GoodsID, Title: &title},
Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"黑色"}}},
ColorPrices: []ResultColorPrice{{Color: "黑色", PriceCent: 1200}},
SKUs: []ResultSKU{{Specs: map[string]string{"color": "黑色"}, PriceCent: 1200, Available: true}},
}, token)
if err != nil || result.Task.ID != created.TaskID || result.Task.Status != models.TaskStatusCompleted {
t.Fatalf("complete retried collection: %+v, %v", result, err)
}
terminal := load()
request.RequestID = uuid.NewString()
if _, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token); taskErrorCode(t, err) != CodeTaskStateConflict {
t.Fatalf("expected terminal-state rejection: %v", err)
}
if !reflect.DeepEqual(terminal, load()) {
t.Fatal("terminal identification changed the task")
}
var archived []models.CollectionTaskAttempt
if err := db.Where("task_id = ?", created.TaskID).Find(&archived).Error; err != nil {
t.Fatal(err)
}
if len(archived) != 1 || archived[0].AttemptNumber != 1 || archived[0].Status != models.TaskStatusFailed ||
archived[0].ErrorCode == nil || *archived[0].ErrorCode != failureCode {
t.Fatal("original failed attempt was not preserved")
}
for _, model := range []any{&models.CollectionTask{}, &models.PDDProduct{}} {
var count int64
if err := db.Model(model).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("retry duplicated task/product: count=%d, %v", count, err)
}
}
}
+373
View File
@@ -0,0 +1,373 @@
package task
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"go-admin/app/goauto/device"
"go-admin/app/goauto/models"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const ImageSearchCapability = "pdd.image-search.v1"
const imageSearchMaxBytes int64 = 10 << 20
func validImageMetadata(value ImageSearchImage) bool {
if value.SizeBytes <= 0 || value.SizeBytes > imageSearchMaxBytes || len(value.SHA256) != 64 || value.SHA256 != strings.ToLower(value.SHA256) || value.ImageURL == "" {
return false
}
return value.MediaType == "image/jpeg" || value.MediaType == "image/png"
}
func fetchImageSearchImage(ctx context.Context, rawURL string) (ImageSearchImage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return ImageSearchImage{}, err
}
response, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return ImageSearchImage{}, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return ImageSearchImage{}, fmt.Errorf("reference image status %d", response.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(response.Body, imageSearchMaxBytes+1))
if err != nil || int64(len(data)) > imageSearchMaxBytes {
return ImageSearchImage{}, errors.New("reference image exceeds limit")
}
config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || config.Width < 1 || config.Height < 1 {
return ImageSearchImage{}, errors.New("unsupported image format")
}
media := "image/" + format
if format == "jpeg" {
media = "image/jpeg"
}
hash := sha256.Sum256(data)
return ImageSearchImage{ImageURL: rawURL, MediaType: media, SizeBytes: int64(len(data)), SHA256: hex.EncodeToString(hash[:])}, nil
}
// Only this subset is sent to Agent. Association and price snapshots stay on Server.
type ImageSearchImage struct {
ImageURL string `json:"imageUrl"`
MediaType string `json:"mediaType"`
SizeBytes int64 `json:"sizeBytes"`
SHA256 string `json:"sha256"`
}
type ImageSearchSnapshot struct {
ImageSearchImage
ShopeeProductID uint64 `json:"shopeeProductId"`
SYBProductIDs []uint64 `json:"sybProductIds"`
RepresentativeSYBProductID uint64 `json:"representativeSybProductId"`
OriginalPDDProductID *uint64 `json:"originalPddProductId"`
ReferencePriceCent int64 `json:"referencePriceCent"`
ReferenceCurrency string `json:"referenceCurrency"`
MaxPriceRatio int64 `json:"maxPriceRatio"`
PriceGuardSkipped bool `json:"priceGuardSkipped"`
PriceGuardSkipReason string `json:"priceGuardSkipReason,omitempty"`
RequestFingerprint string `json:"requestFingerprint"`
}
type ImageSearchBatchRequest struct {
RequestID string `json:"requestId"`
SYBProductIDs []uint64 `json:"sybProductIds"`
RuleID uint64 `json:"ruleId"`
DeviceID *uint64 `json:"deviceId"`
OverwriteLinked bool `json:"overwriteLinked"`
}
type ImageSearchBatchItem struct {
ShopeeProductID uint64 `json:"shopeeProductId"`
SYBProductIDs []uint64 `json:"sybProductIds"`
Success bool `json:"success"`
TaskID uint64 `json:"taskId,omitempty"`
Replayed bool `json:"replayed,omitempty"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
type ImageSearchBatchResponse struct {
Items []ImageSearchBatchItem `json:"items"`
SuccessCount int `json:"successCount"`
FailureCount int `json:"failureCount"`
SkippedCount int `json:"skippedCount"`
}
func (service *Service) BatchCreateImageSearch(ctx context.Context, request ImageSearchBatchRequest) (ImageSearchBatchResponse, error) {
batchID, err := uuid.Parse(strings.TrimSpace(request.RequestID))
if err != nil || request.RuleID == 0 || len(request.SYBProductIDs) == 0 || len(request.SYBProductIDs) > 100 || request.DeviceID != nil && *request.DeviceID == 0 {
return ImageSearchBatchResponse{}, serviceError("INVALID_REQUEST", "批次、SYB 商品、规则和设备参数无效(最多 100 条)")
}
seen := map[uint64]bool{}
for _, id := range request.SYBProductIDs {
if id == 0 || seen[id] {
return ImageSearchBatchResponse{}, serviceError("INVALID_REQUEST", "SYB 商品列表包含无效或重复 ID")
}
seen[id] = true
}
// Bound the complete synchronous batch, not just each individual download.
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
var rows []models.SYBProduct
if err := service.DB.WithContext(ctx).Where("id IN ?", request.SYBProductIDs).Find(&rows).Error; err != nil {
return ImageSearchBatchResponse{}, internalError(err)
}
byID := map[uint64]models.SYBProduct{}
for _, row := range rows {
byID[row.ID] = row
}
response := ImageSearchBatchResponse{Items: []ImageSearchBatchItem{}}
groups := map[uint64]int{}
for _, id := range request.SYBProductIDs {
row, found := byID[id]
if !found || row.ShopeeProductID == nil || *row.ShopeeProductID == 0 {
response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "SYB_PRODUCT_UNAVAILABLE", Message: "SYB 商品不存在或未关联蝦皮商品"})
continue
}
if index, ok := groups[*row.ShopeeProductID]; ok {
response.Items[index].SYBProductIDs = append(response.Items[index].SYBProductIDs, id)
} else {
groups[*row.ShopeeProductID] = len(response.Items)
response.Items = append(response.Items, ImageSearchBatchItem{ShopeeProductID: *row.ShopeeProductID, SYBProductIDs: []uint64{id}})
}
}
for index := range response.Items {
item := &response.Items[index]
if item.Code == "" {
itemRequestID := uuid.NewSHA1(batchID, []byte(strconv.FormatUint(item.ShopeeProductID, 10))).String()
id, replayed, createErr := service.createImageSearch(ctx, request, *item, itemRequestID)
if createErr == nil {
item.Success, item.TaskID, item.Replayed = true, id, replayed
} else {
var target *ServiceError
if !errors.As(createErr, &target) {
target = internalError(createErr).(*ServiceError)
}
item.Code, item.Message = target.Code, target.Message
}
}
if item.Success {
response.SuccessCount++
} else if item.Code == "IMAGE_SEARCH_ALREADY_LINKED" {
response.SkippedCount++
} else {
response.FailureCount++
}
}
return response, nil
}
func imageRequestFingerprint(request ImageSearchBatchRequest, item ImageSearchBatchItem) string {
// Include group membership so a reused request ID cannot silently change intent.
request.RequestID = ""
request.SYBProductIDs = item.SYBProductIDs
raw, _ := json.Marshal(request)
hash := sha256.Sum256(raw)
return hex.EncodeToString(hash[:])
}
func loadImageSearchSnapshot(record models.CollectionTask) (ImageSearchSnapshot, error) {
var snapshot ImageSearchSnapshot
if record.ImageSearchSnapshot == nil || json.Unmarshal([]byte(*record.ImageSearchSnapshot), &snapshot) != nil || snapshot.ShopeeProductID == 0 || snapshot.RepresentativeSYBProductID == 0 || snapshot.MaxPriceRatio < 1 || !validImageMetadata(snapshot.ImageSearchImage) {
return snapshot, serviceError("IMAGE_SEARCH_SNAPSHOT_INVALID", "图搜任务快照无效")
}
return snapshot, nil
}
// ImageSearchPriceAllowed deliberately refuses to compare unlike currencies.
// A skipped check is returned as comparable=false so callers must require
// manual confirmation before automatic association.
func ImageSearchPriceAllowed(snapshot ImageSearchSnapshot, pddCurrency string, priceCent int64) (allowed bool, comparable bool) {
if priceCent < 0 || snapshot.ReferencePriceCent < 0 || snapshot.ReferencePriceCent == 0 || snapshot.MaxPriceRatio < 1 {
return false, false
}
if strings.ToUpper(strings.TrimSpace(snapshot.ReferenceCurrency)) != strings.ToUpper(strings.TrimSpace(pddCurrency)) {
return false, false
}
return priceCent <= snapshot.ReferencePriceCent*snapshot.MaxPriceRatio, true
}
func (service *Service) createImageSearch(ctx context.Context, request ImageSearchBatchRequest, item ImageSearchBatchItem, requestID string) (uint64, bool, error) {
fingerprint := imageRequestFingerprint(request, item)
replay := func(db *gorm.DB) (uint64, bool, error) {
var record models.CollectionTask
err := db.Where("create_request_id = ?", requestID).First(&record).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, false, nil
}
if err != nil {
return 0, false, internalError(err)
}
snapshot, err := loadImageSearchSnapshot(record)
if err != nil {
return 0, false, err
}
if record.Source != models.CollectionTaskSourceImageSearch || snapshot.RequestFingerprint != fingerprint {
return 0, false, serviceError("IMAGE_SEARCH_REQUEST_CONFLICT", "同一请求 ID 的参数已变化")
}
return record.ID, true, nil
}
db := service.DB.WithContext(ctx)
if id, found, err := replay(db); found || err != nil {
return id, found, err
}
var shopee models.ShopeeProduct
if err := db.First(&shopee, item.ShopeeProductID).Error; err != nil {
return 0, false, serviceError("SHOPEE_PRODUCT_NOT_FOUND", "蝦皮商品不存在")
}
if shopee.PDDProductID != nil && !request.OverwriteLinked {
return 0, false, serviceError("IMAGE_SEARCH_ALREADY_LINKED", "商品已关联 PDD,已跳过")
}
var rule models.CollectionRule
if err := db.First(&rule, request.RuleID).Error; err != nil {
return 0, false, serviceError(CodeRuleNotFound, "采集规则不存在")
}
if err := ensureCurrentPageRule(rule.ContentJSON); err != nil {
return 0, false, err
}
if request.DeviceID != nil {
var target models.AgentDevice
if err := db.First(&target, *request.DeviceID).Error; err != nil || target.Status == models.DeviceStatusDisabled {
return 0, false, serviceError(CodeDeviceNotFound, "设备不存在或已停用")
}
if err := ensureTaskCompatible(target, models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, RuleSnapshot: rule.ContentJSON}); err != nil {
return 0, false, err
}
}
ratio := int64(3)
if value := strings.TrimSpace(os.Getenv("GOAUTO_IMAGE_SEARCH_MAX_PRICE_RATIO")); value != "" {
var err error
ratio, err = strconv.ParseInt(value, 10, 64)
if err != nil || ratio < 1 || ratio > 100 {
return 0, false, serviceError("IMAGE_SEARCH_CONFIG_INVALID", "图搜价格倍数配置须为 1~100 的整数")
}
}
var representative models.SYBProduct
var image ImageSearchImage
fetch := service.FetchImageSearchImage
if fetch == nil {
fetch = fetchImageSearchImage
}
for _, id := range item.SYBProductIDs {
var row models.SYBProduct
if err := db.First(&row, id).Error; err != nil {
return 0, false, internalError(err)
}
if row.ShopeeProductID == nil || *row.ShopeeProductID != shopee.ID {
return 0, false, serviceError("IMAGE_SEARCH_INPUT_CHANGED", "SYB 商品关联已变化,请刷新后重试")
}
if strings.TrimSpace(row.ImageURL) == "" {
continue
}
var err error
image, err = fetch(ctx, row.ImageURL)
if err == nil && validImageMetadata(image) {
representative = row
break
}
if ctx.Err() != nil {
break
}
}
if representative.ID == 0 {
return 0, false, serviceError("IMAGE_SEARCH_IMAGE_INVALID", "参考图下载失败、格式不支持或超过限制")
}
referenceCurrency := strings.ToUpper(strings.TrimSpace(shopee.Currency))
priceSkipped := referenceCurrency != "CNY"
snapshot := ImageSearchSnapshot{ImageSearchImage: image, ShopeeProductID: shopee.ID, SYBProductIDs: item.SYBProductIDs, RepresentativeSYBProductID: representative.ID,
OriginalPDDProductID: shopee.PDDProductID, ReferencePriceCent: representative.UnitPriceCent, ReferenceCurrency: referenceCurrency, MaxPriceRatio: ratio, RequestFingerprint: fingerprint,
PriceGuardSkipped: priceSkipped}
if priceSkipped {
snapshot.PriceGuardSkipReason = "CROSS_CURRENCY_TWD_PDD_CNY"
}
raw, _ := json.Marshal(snapshot)
stored := string(raw)
var taskID uint64
var replayed bool
err := db.Transaction(func(tx *gorm.DB) error {
var current models.ShopeeProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, shopee.ID).Error; err != nil {
return internalError(err)
}
if id, found, err := replay(tx); found || err != nil {
taskID, replayed = id, found
return err
}
if !sameImageSearchLink(current.PDDProductID, shopee.PDDProductID) || current.Currency != shopee.Currency {
return serviceError("IMAGE_SEARCH_INPUT_CHANGED", "蝦皮商品关联或币种已变化,请刷新后重试")
}
for _, id := range item.SYBProductIDs {
var row models.SYBProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
return internalError(err)
}
if row.ShopeeProductID == nil || *row.ShopeeProductID != shopee.ID || id == representative.ID && (row.ImageURL != representative.ImageURL || row.UnitPriceCent != representative.UnitPriceCent) {
return serviceError("IMAGE_SEARCH_INPUT_CHANGED", "参考图片或价格已变化,请刷新后重试")
}
}
var active []models.CollectionTask
if err := tx.Where("source = ? AND status IN ?", models.CollectionTaskSourceImageSearch, []string{models.TaskStatusPending, models.TaskStatusRunning}).Find(&active).Error; err != nil {
return internalError(err)
}
for _, candidate := range active {
other, err := loadImageSearchSnapshot(candidate)
if err != nil {
return err
}
if other.ShopeeProductID == shopee.ID {
return serviceError("IMAGE_SEARCH_TASK_ACTIVE", "该蝦皮商品已有未结束的图搜任务")
}
}
record := models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, Status: models.TaskStatusPending, RuleID: rule.ID, RuleSnapshot: rule.ContentJSON, DeviceID: request.DeviceID, CreateRequestID: &requestID, ImageSearchSnapshot: &stored}
if err := tx.Create(&record).Error; err != nil {
return internalError(err)
}
taskID = record.ID
return nil
})
return taskID, replayed, err
}
func sameImageSearchLink(left, right *uint64) bool {
return left == nil && right == nil || left != nil && right != nil && *left == *right
}
func taskCompatible(record models.AgentDevice, task models.CollectionTask) (bool, error) {
if task.Source == models.CollectionTaskSourceImageSearch {
ok, err := device.Supports(record, []string{ImageSearchCapability})
if err != nil || !ok {
return false, err
}
}
return ruleCompatible(record, task.RuleSnapshot)
}
func ensureTaskCompatible(record models.AgentDevice, task models.CollectionTask) error {
ok, err := taskCompatible(record, task)
if err != nil {
return err
}
if !ok {
return serviceError(CodeDeviceCapabilityMismatch, "设备不支持任务所需能力,请更新 Agent")
}
return nil
}
@@ -0,0 +1,60 @@
package task
import (
"context"
"errors"
"strings"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
// autoLinkImageSearch is deliberately a narrow post-result hook. It only
// writes an association when the task was a complete collection, the frozen
// original association is unchanged, and the target product is active. Manual
// changes made while the Agent was running win by the CAS predicate.
func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64) error {
var task models.CollectionTask
if err := service.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
return internalError(err)
}
if task.Source != models.CollectionTaskSourceImageSearch || task.Status != models.TaskStatusCompleted || task.PDDProductID == nil || task.ImageSearchSnapshot == nil {
return nil
}
snapshot, err := loadImageSearchSnapshot(task)
if err != nil {
return err
}
if snapshot.PriceGuardSkipped {
return nil
}
var product models.ShopeeProduct
if err := service.DB.WithContext(ctx).First(&product, snapshot.ShopeeProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return internalError(err)
}
// PDD products are RMB in the current contract; unlike-currency snapshots
// must remain available for manual review and can never auto-link.
if strings.ToUpper(strings.TrimSpace(product.Currency)) != "CNY" {
return nil
}
var pdd models.PDDProduct
if err := service.DB.WithContext(ctx).Select("id", "status").First(&pdd, *task.PDDProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return internalError(err)
}
if pdd.Status != "active" {
return nil
}
result := service.DB.WithContext(ctx).Model(&models.ShopeeProduct{}).
Where("id = ? AND pdd_product_id IS NULL AND image_search_linked = ?", product.ID, false).
Updates(map[string]any{"pdd_product_id": *task.PDDProductID, "image_search_linked": true})
if result.Error != nil {
return internalError(result.Error)
}
return nil
}
@@ -0,0 +1,81 @@
package task
import (
"context"
"encoding/json"
"strings"
"testing"
"go-admin/app/goauto/models"
)
func TestImageSearchPriceAllowedRefusesCrossCurrency(t *testing.T) {
snapshot := ImageSearchSnapshot{ReferencePriceCent: 1000, ReferenceCurrency: "TWD", MaxPriceRatio: 3}
if allowed, comparable := ImageSearchPriceAllowed(snapshot, "CNY", 2000); allowed || comparable {
t.Fatalf("cross-currency price must not be compared: allowed=%v comparable=%v", allowed, comparable)
}
if allowed, comparable := ImageSearchPriceAllowed(snapshot, "TWD", 3000); !allowed || !comparable {
t.Fatalf("same-currency price within ratio should pass: allowed=%v comparable=%v", allowed, comparable)
}
if allowed, comparable := ImageSearchPriceAllowed(snapshot, "TWD", 3001); allowed || !comparable {
t.Fatalf("same-currency price over ratio should fail: allowed=%v comparable=%v", allowed, comparable)
}
}
func TestImageSearchPayloadOnlyContainsImageMetadata(t *testing.T) {
secret := `{"shopeeProductId":99,"representativeSybProductId":1,"maxPriceRatio":3,"referenceCurrency":"TWD","imageUrl":"https://example.invalid/ref.jpg","mediaType":"image/jpeg","sizeBytes":10,"sha256":"0123456789012345678901234567890101234567890123456789012345678901"}`
record := models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, RuleSnapshot: `{}`, ImageSearchSnapshot: &secret}
payload, err := NewService(nil).payload(record, false)
if err != nil {
t.Fatal(err)
}
raw := string(payload.ImageSearch)
if raw == "" || containsAny(raw, "shopeeProductId", "referenceCurrency") {
t.Fatalf("internal snapshot fields leaked in Agent payload: %s", raw)
}
}
func containsAny(value string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(value, needle) {
return true
}
}
return false
}
func TestAutoLinkImageSearchDoesNotOverwriteManualAssociation(t *testing.T) {
db := openTaskDatabase(t)
manualPDD := models.PDDProduct{GoodsID: "manual-pdd", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=manual-pdd", Status: "active"}
autoPDD := models.PDDProduct{GoodsID: "auto-pdd", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=auto-pdd", Status: "active"}
if err := db.Create(&manualPDD).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&autoPDD).Error; err != nil {
t.Fatal(err)
}
shopee := models.ShopeeProduct{ShopeeItemID: "auto-link-test", Title: "test", Currency: "CNY", SpecsJSON: "[]", PDDProductID: &manualPDD.ID}
if err := db.Create(&shopee).Error; err != nil {
t.Fatal(err)
}
rule := models.CollectionRule{Name: "image-search-test", ContentJSON: "{}"}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
snapshot := ImageSearchSnapshot{ShopeeProductID: shopee.ID, RepresentativeSYBProductID: 1, ReferenceCurrency: "CNY", ReferencePriceCent: 100, MaxPriceRatio: 3, ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"}}
raw, _ := json.Marshal(snapshot)
task := models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, Status: models.TaskStatusCompleted, PDDProductID: &autoPDD.ID, RuleID: rule.ID, RuleSnapshot: "{}", ImageSearchSnapshot: func() *string { v := string(raw); return &v }()}
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
if err := NewService(db).autoLinkImageSearch(context.Background(), task.ID); err != nil {
t.Fatal(err)
}
var saved models.ShopeeProduct
if err := db.First(&saved, shopee.ID).Error; err != nil {
t.Fatal(err)
}
if saved.PDDProductID == nil || *saved.PDDProductID != manualPDD.ID || saved.ImageSearchLinked {
t.Fatalf("manual association was overwritten: %+v", saved)
}
}
+3
View File
@@ -123,6 +123,9 @@ func (service *Service) SubmitResult(ctx context.Context, taskID uint64, request
if err := service.activatePersistedReplacement(ctx, taskID); err != nil {
return DetailResponse{}, err
}
if err := service.autoLinkImageSearch(ctx, taskID); err != nil {
return DetailResponse{}, err
}
detail, err := service.Detail(ctx, taskID)
detail.Replayed = replayed
return detail, err
+1
View File
@@ -31,6 +31,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
admin.GET("", handler.AdminList)
admin.POST("", handler.AdminCreate)
admin.POST("/batch", handler.AdminBatchCreate)
admin.POST("/image-search/batch", handler.AdminBatchCreateImageSearch)
admin.GET("/:taskId", handler.AdminDetail)
admin.POST("/:taskId/reset", handler.AdminReset)
admin.DELETE("/:taskId", handler.AdminDelete)
+18 -4
View File
@@ -60,6 +60,7 @@ type TaskPayload struct {
ReplacementOriginType string `json:"replacementOriginType,omitempty"`
RuleID uint64 `json:"ruleId"`
RuleSnapshot json.RawMessage `json:"ruleSnapshot"`
ImageSearch json.RawMessage `json:"imageSearch,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds"`
LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"`
LeaseVersion uint64 `json:"leaseVersion"`
@@ -74,6 +75,7 @@ type Service struct {
TaskTimeout int
ResolveCurrentPageShare func(context.Context, string) (ResolvedPDDShare, error)
ColorImageDirectory string
FetchImageSearchImage func(context.Context, string) (ImageSearchImage, error)
}
func NewService(db *gorm.DB) *Service {
@@ -107,7 +109,7 @@ func (service *Service) Next(ctx context.Context, token string) (*TaskPayload, e
Where("device_id = ? AND status = ? AND lease_expires_at > ?", deviceRecord.ID, models.TaskStatusPending, now).
Order("created_at ASC, id ASC").First(&record).Error
if err == nil {
if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil {
if err := ensureTaskCompatible(deviceRecord, record); err != nil {
return nil, err
}
payload, payloadErr := service.payload(record, false)
@@ -124,7 +126,7 @@ func (service *Service) Next(ctx context.Context, token string) (*TaskPayload, e
return nil, internalError(err)
}
for _, candidate := range candidates {
compatible, compatibilityErr := ruleCompatible(deviceRecord, candidate.RuleSnapshot)
compatible, compatibilityErr := taskCompatible(deviceRecord, candidate)
if compatibilityErr != nil {
return nil, compatibilityErr
}
@@ -167,7 +169,7 @@ func (service *Service) Claim(ctx context.Context, taskID uint64, request Action
if record.Status != models.TaskStatusPending {
return serviceError(CodeTaskStateConflict, "只有待执行任务可以领取")
}
if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil {
if err := ensureTaskCompatible(deviceRecord, record); err != nil {
return err
}
now := service.Now()
@@ -235,7 +237,7 @@ func (service *Service) Start(ctx context.Context, taskID uint64, request Action
if record.Status != models.TaskStatusPending {
return serviceError(CodeTaskStateConflict, "任务当前状态不能开始")
}
if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil {
if err := ensureTaskCompatible(deviceRecord, record); err != nil {
return err
}
if record.DeviceID == nil || *record.DeviceID != deviceRecord.ID {
@@ -286,6 +288,17 @@ func (service *Service) payload(record models.CollectionTask, replayed bool) (Ta
if !json.Valid(rule) {
return TaskPayload{}, internalError(errors.New("stored rule snapshot is invalid JSON"))
}
var imageSearch json.RawMessage
if record.ImageSearchSnapshot != nil && *record.ImageSearchSnapshot != "" {
snapshot, snapshotErr := loadImageSearchSnapshot(record)
if snapshotErr != nil {
return TaskPayload{}, snapshotErr
}
imageSearch, _ = json.Marshal(snapshot.ImageSearchImage)
if !json.Valid(imageSearch) {
return TaskPayload{}, internalError(errors.New("stored image search snapshot is invalid JSON"))
}
}
timeout := service.TaskTimeout
if timeout <= 0 {
timeout = DefaultTaskTimeout
@@ -295,6 +308,7 @@ func (service *Service) payload(record models.CollectionTask, replayed bool) (Ta
URLSnapshot: record.URLSnapshot, GoodsIDSnapshot: record.GoodsIDSnapshot,
Source: record.Source, ReplacementOriginType: pointerValue(record.ReplacementOriginType),
RuleID: record.RuleID, RuleSnapshot: rule, TimeoutSeconds: timeout,
ImageSearch: imageSearch,
LeaseExpiresAt: record.LeaseExpiresAt, LeaseVersion: record.LeaseVersion,
Status: record.Status, Replayed: replayed,
}, nil
@@ -0,0 +1,63 @@
package version_local
import (
"runtime"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybshop"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, file, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBProductFilterTable)
}
// migrateSYBProductFilterTable creates syb_product_filter and seeds its rules
// for #269.
//
// `[必须]` Adding the model to migrations.Migrate only covers databases created
// from scratch. Every existing database already has the older version numbers
// recorded in sys_migration, so that list never runs again there — a new model
// needs its own version file, which is what the post-migration table check in
// cmd/migrate enforces.
//
// The seed is deliberately the same set the fresh-database path writes, created
// with FirstOrCreate on the normalized key so running both paths against one
// database cannot produce duplicates.
func migrateSYBProductFilterTable(db *gorm.DB, version string) error {
if err := db.AutoMigrate(&models.SYBProductFilter{}); err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
// 结构判据 is what defines a 档口 product; the keywords are the forward
// safety net for the day those codes stop carrying "#" (#269).
seed := []struct{ kind, keyword string }{
{"char", "-"},
{"char", "#"},
{"keyword", "CVC短袖220斤"},
{"keyword", "180克純棉圓領短袖"},
{"keyword", "拼色純棉短袖"},
{"keyword", "300斤牛奶絲圓領"},
{"keyword", "印花大學T"},
{"keyword", "印花帽T"},
}
for _, item := range seed {
norm := sybshop.Normalize(item.keyword)
row := models.SYBProductFilter{
Kind: item.kind,
Keyword: item.keyword,
NormalizedKeyword: norm,
Enabled: true,
}
if err := tx.Where("kind = ? AND normalized_keyword = ?", item.kind, norm).
FirstOrCreate(&row).Error; err != nil {
return err
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,91 @@
package version_local
import (
"fmt"
"runtime"
"go-admin/app/goauto/access"
"go-admin/cmd/migrate/migration"
migrationmodels "go-admin/cmd/migrate/migration/models"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, file, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBProductFilterMenu)
}
// migrateSYBProductFilterMenu adds the 「SYB 商品过滤」 menu entry for #269.
//
// `[必须]` Adding a module to access.GoAutoModules() is not enough to make its
// page reachable. Menus are written by the versioned migration that first
// created them, and that version is already recorded in sys_migration on every
// existing database — it never runs again. Startup only reconciles the casbin
// API matrix (access.ReconcilePurchaserPermissions), not sys_menu. Without this
// migration the page exists, the API answers and the route resolves, yet nobody
// can navigate to it on an already-migrated database.
func migrateSYBProductFilterMenu(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
module, ok := goAutoModuleByKey(access.ModuleSYBProductFilters)
if !ok {
return fmt.Errorf("module %q is not registered in access.GoAutoModules()", access.ModuleSYBProductFilters)
}
// The page belongs beside 「SYB 店铺」 under 采采管理, which is where the
// admin already manages which SYB data enters the system.
var parent migrationmodels.SysMenu
if err := tx.Where("menu_name = ?", "GoAutoCollectionManagement").First(&parent).Error; err != nil {
return err
}
child, _, err := upsertGoAutoMenu(tx, migrationmodels.SysMenu{
MenuName: module.RouteName,
Title: module.Title,
Icon: module.Icon,
Path: module.Path + "/index",
MenuType: "C",
Action: "无",
ParentId: parent.MenuId,
Component: module.Component,
Sort: module.Sort,
Visible: "0",
IsFrame: "1",
})
if err != nil {
return err
}
if err = tx.Model(&child).Update("paths", fmt.Sprintf("/0/%d/%d", parent.MenuId, child.MenuId)).Error; err != nil {
return err
}
// Bind to both roles the module declares. 采购员 needs it because the
// module is PurchaserDefault; 管理员 needs it because only an admin may
// change the rules (the write routes are RequireRoleKey("admin")).
roleKeys := []string{"admin"}
if module.PurchaserDefault {
roleKeys = append(roleKeys, access.RolePurchaser)
}
for _, key := range roleKeys {
var role migrationmodels.SysRole
if err = tx.Where("role_key = ?", key).First(&role).Error; err != nil {
return err
}
if err = tx.Model(&role).Association("SysMenu").Append(&child); err != nil {
return err
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func goAutoModuleByKey(key string) (access.ModuleDefinition, bool) {
for _, module := range access.GoAutoModules() {
if module.Key == key {
return module, true
}
}
return access.ModuleDefinition{}, false
}
@@ -0,0 +1,27 @@
package version_local
import (
"go-admin/app/goauto/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() { migration.Migrate.SetVersion("1789200000000", migrateSYBParseStatusBinary) }
// migrateSYBParseStatusBinary folds legacy uncertain rows into failed and
// narrows the check constraint to the two supported states (#272).
func migrateSYBParseStatusBinary(db *gorm.DB, version string) error {
if err := db.Model(&models.SYBProduct{}).Where("parse_status = ?", "uncertain").Update("parse_status", models.SYBParseStatusFailed).Error; err != nil {
return err
}
if db.Dialector.Name() == "mysql" {
if err := db.Exec("ALTER TABLE syb_product DROP CHECK ck_syb_product_parse_status").Error; err != nil {
return err
}
if err := db.Exec("ALTER TABLE syb_product ADD CONSTRAINT ck_syb_product_parse_status CHECK (parse_status IN ('success','failed'))").Error; err != nil {
return err
}
}
return db.Create(&common.Migration{Version: version}).Error
}
@@ -0,0 +1,36 @@
package version_local
import (
"runtime"
"go-admin/app/goauto/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateImageSearchCollection)
}
// #278 permits image-search tasks while preserving existing task rows.
func migrateImageSearchCollection(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if !tx.Migrator().HasColumn(&models.CollectionTask{}, "ImageSearchSnapshot") {
if err := tx.Migrator().AddColumn(&models.CollectionTask{}, "ImageSearchSnapshot"); err != nil {
return err
}
}
if tx.Dialector.Name() == "mysql" {
// MySQL does not support ALTER CHECK in place. The migration runner
// records this version once, so dropping/recreating the named check is
// deterministic and does not trigger a whole-schema AutoMigrate.
_ = tx.Exec("ALTER TABLE collection_task DROP CHECK ck_collection_task_source").Error
if err := tx.Exec("ALTER TABLE collection_task ADD CONSTRAINT ck_collection_task_source CHECK (source IN ('admin','agent_current_page','image_search'))").Error; err != nil {
return err
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,25 @@
package version_local
import (
"runtime"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateImageSearchLinkMarker)
}
// #279 stores whether the current Shopee→PDD link was created by image search.
func migrateImageSearchLinkMarker(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := goautomigrations.Migrate(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
+1
View File
@@ -3,6 +3,7 @@ import request from '@/utils/request'
export function listCollectionTasks(params) { return request({ url: '/api/admin/v1/collection-tasks', method: 'get', params }) }
export function createCollectionTask(data) { return request({ url: '/api/admin/v1/collection-tasks', method: 'post', data }) }
export function batchCreateCollectionTasks(data) { return request({ url: '/api/admin/v1/collection-tasks/batch', method: 'post', data }) }
export function batchCreateImageSearchCollectionTasks(data) { return request({ url: '/api/admin/v1/collection-tasks/image-search/batch', method: 'post', data }) }
export function getCollectionTask(taskId) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}`, method: 'get' }) }
export function resetCollectionTask(taskId, data) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}/reset`, method: 'post', data }) }
export function deleteCollectionTask(taskId, data) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}`, method: 'delete', data }) }
+9 -6
View File
@@ -8,16 +8,16 @@ export function createShopeeProduct(data) {
return request({ url: '/api/admin/v1/shopee-products', method: 'post', data })
}
export function getShopeeProduct(productId) {
return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'get' })
export function getShopeeProduct(productId, options = {}) {
return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'get', ...options })
}
export function updateShopeeProduct(productId, data) {
return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'patch', data })
}
export function linkShopeeProductPdd(productId, data) {
return request({ url: `/api/admin/v1/shopee-products/${productId}/link-pdd`, method: 'post', data })
export function linkShopeeProductPdd(productId, data, options = {}) {
return request({ url: `/api/admin/v1/shopee-products/${productId}/link-pdd`, method: 'post', data, ...options })
}
export function restoreShopeeProduct(productId, data) {
@@ -66,8 +66,11 @@ export function suggestShopeeSizeMappings(productId, data) {
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/suggest-sizes`, method: 'post', data, timeout: aiSuggestTimeoutMs })
}
export function autoMatchShopeeSpecMappings(productId, data) {
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/auto-match`, method: 'post', data, timeout: aiSuggestTimeoutMs })
export function autoMatchShopeeSpecMappings(productId, data, timeoutSeconds) {
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 16 || timeoutSeconds > 1210) {
return Promise.reject(new Error('匹配等待时间不可用,请刷新页面后重试'))
}
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/auto-match`, method: 'post', data, timeout: (timeoutSeconds + 10) * 1000, suppressErrorMessage: true })
}
export function startShopeeSpecAutoMatchRun(data) {
@@ -0,0 +1,6 @@
import request from '@/utils/request'
const base = '/api/admin/v1/syb-product-filters'
export function listSybProductFilters(params) { return request({ url: base, method: 'get', params }) }
export function createSybProductFilter(data) { return request({ url: base, method: 'post', data }) }
export function setSybProductFilterEnabled(id, data) { return request({ url: `${base}/${id}/enabled`, method: 'patch', data }) }
export function deleteSybProductFilter(id) { return request({ url: `${base}/${id}`, method: 'delete' }) }
@@ -0,0 +1,24 @@
// Separate from the purchase-device preference; browser origin provides the
// outer isolation and API environment + user identify the inner namespace.
function key(userId) {
const id = Number(userId)
return Number.isSafeInteger(id) && id > 0 ? `goauto:quick-link-device:${encodeURIComponent(process.env.VUE_APP_BASE_API || '/')}:user:${id}` : null
}
export function readQuickLinkDevice(userId) {
try {
const name = key(userId)
if (!name) return null
const id = JSON.parse(localStorage.getItem(name) || 'null')
return Number.isSafeInteger(id) && id > 0 ? id : null
} catch { return null }
}
export function rememberQuickLinkDevice(userId, deviceId) {
try {
const name = key(userId)
if (!name || !Number.isSafeInteger(deviceId) || deviceId <= 0) return false
localStorage.setItem(name, JSON.stringify(deviceId))
return true
} catch { return false }
}

Some files were not shown because too many files have changed in this diff Show More