Compare commits

...
Author SHA1 Message Date
QiuSW 659bbc6e1a docs: describe local purchase diagnostic extraction #249 2026-09-09 11:18:12 +08:00
QiuSW 99faf5ad95 feat(android): persist bounded sanitized purchase entry diagnostics #249 2026-09-09 11:15:08 +08:00
QiuSW 8a3367a4ea docs: record scoped dynamic purchase footer contract #248 2026-09-09 10:48:33 +08:00
QiuSW c80c746943 fix(android): locate dynamic submit footer after address save #248 2026-09-09 10:45:44 +08:00
QiuSW bec8c84321 fix(android): recover saved address in short spec panels #247 2026-09-09 10:03:00 +08:00
QiuSW 7ea34e32c1 fix(android): restore address evidence after returning to spec panel #246 2026-09-09 09:48:10 +08:00
QiuSW d1ddb04959 docs: record exact spec availability diagnostics #245 2026-09-09 09:24:48 +08:00
QiuSW ca815c30de fix(android): clarify entry recovery and unavailable exact specs #245 2026-09-09 09:21:27 +08:00
QiuSW 66e0bdfc07 docs: document same-card selection exception (#244) 2026-09-08 18:12:12 +08:00
QiuSW 93aab6a59b fix(android): ignore selected duplicates within exact color card (#244) 2026-09-08 18:07:06 +08:00
QiuSW ec4802dfe7 docs: define scoped purchase entry recovery (#243) 2026-09-08 17:46:31 +08:00
QiuSW ec8b14ae21 fix(android): recover spec entry despite product page animation (#243) 2026-09-08 17:42:05 +08:00
QiuSW 7ac1355a71 docs(agent): document on-demand spec panel scrolling and diagnostics (#238) 2026-09-07 21:36:17 +08:00
QiuSW 58a6c1c86d fix(agent): replace mandatory spec panel preswipe with on-demand search (#238) 2026-09-07 21:33:31 +08:00
QiuSW 9d5242b371 docs(local): record verified client key migration and service startup (#237) 2026-09-07 17:45:38 +08:00
QiuSW 9a10d82cf4 chore(local): run GoAuto from main runtime with explicit migrations (#237) 2026-09-07 17:41:34 +08:00
QiuSW ac3c63ead6 docs(client-api): record approved HTTP compatibility (#237) 2026-09-07 17:29:28 +08:00
QiuSW 71f7751754 fix(client-api): support HTTP and HTTPS by default (#237) 2026-09-07 17:25:55 +08:00
QiuSW 76c94e33e0 docs(client-api): sync key contracts and deployment prerequisites (#237) 2026-09-07 17:15:02 +08:00
QiuSW 57b0f1595f feat(client-api): add scoped editable client keys (#237) 2026-09-07 16:20:46 +08:00
QiuSW 29ba16e4f9 feat(syb): allow purchaser manual sync (#236) 2026-09-07 15:03:31 +08:00
QiuSW ff2d0ca0e5 fix(syb): recover bounded today pagination overlaps (#235) 2026-09-07 14:25:47 +08:00
QiuSW bf58ad0005 merge: integrate main and purchase fixes for release (#234) 2026-09-07 11:58:24 +08:00
QiuSW 1900dab32e feat(web): remember batch purchase device per user (#233) 2026-09-07 11:25:12 +08:00
QiuSW 0cb36b1e73 fix(agent): retain purchase panel recognition after heading scroll (#231) 2026-09-07 11:05:24 +08:00
QiuSW 3efe4f64a5 docs: sync purchase direct-link entry contract (#232) 2026-09-07 10:55:26 +08:00
QiuSW 486dff29fd fix(agent): launch purchase deep links with fresh PDD task (#232) 2026-09-07 10:51:24 +08:00
QiuSW c8e5b99b0c fix(agent): recognize selected spec panels without summary prefix (#231) 2026-09-07 10:40:12 +08:00
QiuSW 666d19ad66 fix(agent): distinguish nested purchase scroll containers (#230) 2026-09-07 10:21:36 +08:00
QiuSW c2c1044dbb fix(agent): recover vertical size grids after horizontal search failure (#230) 2026-09-07 10:07:39 +08:00
QiuSW 4e6afc2d25 fix(agent): search horizontally for exact purchase size (#230) 2026-09-07 09:55:07 +08:00
QiuSW 7b9fcfb8c1 fix(agent): search horizontally for exact purchase color (#230) 2026-09-07 09:41:54 +08:00
QiuSW 1f40a7fcb1 fix(agent): resolve duplicated semantic address cards (#229) 2026-09-05 18:28:47 +08:00
QiuSW c9aaade6e9 fix(agent): deduplicate address entry nodes (#229) 2026-09-05 18:17:54 +08:00
QiuSW 5ee3b62906 fix(agent): retain exact specs across panel transitions (#228) 2026-09-05 17:58:52 +08:00
QiuSW b829a203dc fix(agent): retain purchase panel after address save (#227) 2026-09-05 17:30:40 +08:00
QiuSW 9320e5528c docs(purchase): sync retry page entry contract (#226) 2026-09-05 17:08:20 +08:00
QiuSW 41fe461f94 fix(android): reopen PDD for manual purchase retries (#226) 2026-09-05 17:00:25 +08:00
QiuSW b306f417d5 docs(agent): sync in-place retry contract (#225) 2026-09-05 16:50:01 +08:00
QiuSW 8f5ff4525e docs(purchase): sync retry business rule (#225) 2026-09-05 16:50:00 +08:00
QiuSW 9124e92ed6 fix(agent): retry purchases in place (#225) 2026-09-05 16:45:52 +08:00
QiuSW 095dbfacbf fix(android): retain verified spec selections (#224) 2026-09-05 16:33:24 +08:00
QiuSW cdcb6930d0 fix(android): revalidate offscreen selected specs (#224) 2026-09-05 16:21:01 +08:00
QiuSW d84f6ddf21 fix(purchase): reuse validated probe mappings (#223) 2026-09-05 15:21:17 +08:00
QiuSW f40331189b fix(android): wait for stable PDD product page (#222) 2026-09-05 12:40:19 +08:00
QiuSW 2895f3d72d fix(purchase): advance past spec confirmation (#220) 2026-09-05 10:54:02 +08:00
QiuSW e4051ed8df feat(purchase): reuse probed PDD page (#219) 2026-09-05 10:34:35 +08:00
QiuSW 5859a819c8 feat(agent): 显示采购结果气泡 (#218) 2026-09-05 10:01:21 +08:00
QiuSW 8d2e61bb72 fix(purchase): 重建 Agent 重试任务 (#217) 2026-09-05 09:06:41 +08:00
QiuSW 96cc2ab700 docs(purchase): 更新 Agent 重试契约 (#217) 2026-09-05 09:03:07 +08:00
QiuSW bd386cfae4 fix(syb): 修正规格颜色尺码反序 (#216) 2026-09-04 19:33:35 +08:00
QiuSW c09ddc8474 feat(purchase): 强制真机探测规格后匹配 (#215) 2026-09-04 19:03:16 +08:00
QiuSW 81c1a7ead2 fix: add scheduled job execution history (#199) 2026-09-02 23:12:48 +08:00
QiuSW aa2ecc6ef7 feat: add scheduled SYB spec AI parsing (#198) 2026-09-02 21:33:14 +08:00
QiuSW f27ec16307 docs: finalize AI timeout Wiki mirror (#197) 2026-09-02 17:41:12 +08:00
QiuSW 1d029d34c7 docs: sync AI timeout operations contract (#197) 2026-09-02 17:39:58 +08:00
QiuSW b12460825c fix: prevent AI matching write timeout (#197) 2026-09-02 17:35:12 +08:00
QiuSW d7924619b6 feat: 增加蝦皮规格定时与手动自动匹配 (#195) 2026-09-01 23:41:11 +08:00
QiuSW 041cd8d03f feat: 一键匹配并确认蝦皮颜色尺码 (#194) 2026-09-01 22:20:48 +08:00
158 changed files with 9745 additions and 711 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 52
versionName = "0.9.39"
versionCode = 80
versionName = "0.9.67"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -49,6 +49,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
screenPolicyHandler.removeCallbacks(screenPolicyRefresh)
screenPolicyHandler.post(screenPolicyRefresh)
}
@@ -18,6 +18,8 @@ import android.util.Log
import android.view.Display
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import cn.ilapage.goauto.agent.ui.PurchaseResultBubbleController
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePresentation
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
@@ -32,6 +34,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
ActivityEvidenceTracker { packageName, className -> isDeclaredActivity(packageName, className) }
}
private var accessibilityButtonCallback: AccessibilityButtonController.AccessibilityButtonCallback? = null
private val purchaseResultBubble by lazy {
PurchaseResultBubbleController(this) { currentPackage() == PDD_PACKAGE }
}
override fun onServiceConnected() {
serviceInfo = serviceInfo.apply {
@@ -48,18 +53,19 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
val packageName = event.packageName?.toString()
if (packageName != null && packageName != lastForegroundPackage) {
lastForegroundPackage = packageName
val foregroundPackage = event.packageName?.toString()
if (foregroundPackage != null && foregroundPackage != lastForegroundPackage) {
lastForegroundPackage = foregroundPackage
foregroundRevision.incrementAndGet()
}
if (packageName == PDD_PACKAGE) lastPddForegroundAt.set(SystemClock.elapsedRealtime())
activityTracker.observe(packageName, event.className?.toString())
if (foregroundPackage == PDD_PACKAGE) lastPddForegroundAt.set(SystemClock.elapsedRealtime())
activityTracker.observe(foregroundPackage, event.className?.toString())
}
}
override fun onInterrupt() = Unit
override fun onDestroy() {
dismissPurchaseResultBubble()
unregisterAccessibilityButton()
if (instance === this) instance = null
super.onDestroy()
@@ -71,6 +77,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
runCatching {
val callback = object : AccessibilityButtonController.AccessibilityButtonCallback() {
override fun onClicked(controller: AccessibilityButtonController) {
dismissPurchaseResultBubble()
AccessibilityButtonPolicy.handleClick(::openAgentPreservingTab)
}
}
@@ -115,6 +122,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
).restore(packageName, timeoutMillis)
fun openAgentPreservingTab(): Boolean = runCatching {
dismissPurchaseResultBubble()
startActivity(
android.content.Intent(this, cn.ilapage.goauto.agent.MainActivity::class.java).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP or android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP)
@@ -123,6 +131,14 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
true
}.getOrDefault(false)
fun showPurchaseResultBubble(presentation: PurchaseResultBubblePresentation) {
purchaseResultBubble.show(presentation)
}
fun dismissPurchaseResultBubble() {
purchaseResultBubble.dismiss()
}
override fun visibleTexts(): List<String> {
val root = rootInActiveWindow ?: return emptyList()
val texts = mutableListOf<String>()
@@ -314,6 +330,42 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
}
}
override fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome {
val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE)
val indexes = target.path.split('/').mapNotNull(String::toIntOrNull)
if (indexes.isEmpty() || indexes.first() != 0 || indexes.size != target.path.split('/').size) {
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
}
var node = root
for (index in indexes.drop(1)) {
node = node.getChild(index)
?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
}
val bounds = Rect().also(node::getBoundsInScreen)
if (!node.isVisibleToUser || !node.isEnabled ||
node.preferredOrDescendantLabel() != target.label ||
node.className?.toString() != target.className ||
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) > 32 ||
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) > 32
) {
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
}
var ancestorDepth = 0
while (!node.isClickable) {
node = node.parent ?: return FreshClickOutcome(
FreshActionResult.FAILED,
FreshClickReason.NO_CLICKABLE_ANCESTOR,
clickableAncestorDepth = ancestorDepth,
)
ancestorDepth++
}
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, 1, ancestorDepth)
} else {
FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.ACTION_CLICK_FALSE, 1, ancestorDepth)
}
}
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult {
val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND
val candidates = mutableListOf<AccessibilityNodeInfo>()
@@ -382,8 +434,16 @@ 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 {
val root = rootInActiveWindow ?: return false
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN
val root = rootInActiveWindow ?: run {
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.ROOT_UNAVAILABLE
return false
}
val candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node }
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
@@ -392,23 +452,37 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
}
val target = (directional.ifEmpty { candidates }).maxByOrNull { candidate ->
Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height() }
} ?: return false
return swipeNode(target, direction, durationMs, preferScrollAction = false)
} ?: run {
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.NO_SCROLLABLE
return false
}
return swipeNode(target, direction, durationMs, preferScrollAction = false,
onFailure = { lastPurchaseSwipeFailure = it })
}
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
val root = rootInActiveWindow ?: return false
val matches = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node ->
val bounds = Rect().also(node::getBoundsInScreen)
if (node.isVisibleToUser && node.isEnabled && node.isScrollable &&
node.className?.toString() == target.className &&
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) <= 32 &&
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32
) matches += node
val candidates = mutableListOf<PurchaseScrollCandidate>()
val liveNodes = mutableMapOf<String, AccessibilityNodeInfo>()
fun visit(node: AccessibilityNodeInfo, path: String) {
if (node.isVisibleToUser && node.isEnabled && node.isScrollable) {
val bounds = Rect().also(node::getBoundsInScreen)
candidates += PurchaseScrollCandidate(
path, node.className?.toString(),
NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom),
)
liveNodes[path] = node
}
for (index in 0 until node.childCount) {
node.getChild(index)?.let { visit(it, "$path/$index") }
}
}
if (matches.size != 1) return false
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = true)
visit(root, "0")
val resolved = PurchaseScrollLocator.locate(
PurchaseScrollCandidate(target.path, target.className, target.bounds), candidates,
) ?: return false
val current = liveNodes[resolved.path] ?: return false
return swipeNode(current, direction, durationMs, preferScrollAction = true)
}
override fun backPurchase(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK)
@@ -422,9 +496,17 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
}.getOrDefault(false)
}
private var lastSpecRowSwipeFailure = "none"
override fun specRowSwipeFailureReason(): String = lastSpecRowSwipeFailure
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
lastSpecRowSwipeFailure = "none"
if (anchor == null) return swipe(SemanticTarget.SPEC_PANEL, direction)
val root = rootInActiveWindow ?: return false
val root = rootInActiveWindow ?: run {
lastSpecRowSwipeFailure = "windowMissing"
return false
}
val matches = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node ->
val bounds = Rect().also(node::getBoundsInScreen)
@@ -442,6 +524,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
if (matches.size != 1) {
if (!SpecSwipeSafety.allowGlobalFallback(direction)) {
lastSpecRowSwipeFailure = if (matches.isEmpty()) "anchorMissing" else "anchorAmbiguous"
Log.i("GoAutoCollector", "swipe direction=$direction result=blocked reason=anchor-not-unique matches=${matches.size}")
return false
}
@@ -465,19 +548,27 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
if (anchoredTarget != null) {
val bounds = Rect().also(anchoredTarget::getBoundsInScreen)
Log.i("GoAutoCollector", "swipe direction=$direction anchored=true bounds=$bounds class=${anchoredTarget.className}")
return swipeNode(
val success = swipeNode(
anchoredTarget,
direction,
preferScrollAction = SpecSwipeSafety.preferAccessibilityScrollAction(direction),
)
if (!success) lastSpecRowSwipeFailure = "gestureFailed"
return success
}
if (!SpecSwipeSafety.allowGlobalFallback(direction)) {
lastSpecRowSwipeFailure = "horizontalContainerMissing"
Log.i("GoAutoCollector", "swipe direction=$direction result=blocked reason=no-anchored-horizontal-container")
return false
}
return swipe(SemanticTarget.SPEC_PANEL, direction)
}
override fun swipeSpecRow(target: SnapshotNode, direction: SwipeDirection): Boolean {
if (direction != SwipeDirection.LEFT && direction != SwipeDirection.RIGHT) return false
return swipeSpec(direction, target)
}
override fun pullDownSpecPanel(anchor: SnapshotNode): Boolean {
if (!anchor.scrollable) return false
val root = rootInActiveWindow ?: return false
@@ -567,16 +658,21 @@ 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 false
if (bounds.width() < 2 || bounds.height() < 2) return failed(PurchaseSwipeFailureReason.INVALID_BOUNDS)
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 false
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return failed(PurchaseSwipeFailureReason.GESTURE_UNSUPPORTED)
val left = bounds.left + bounds.width() * 25 / 100
val right = bounds.left + bounds.width() * 75 / 100
val top = bounds.top + bounds.height() * 25 / 100
@@ -614,8 +710,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
},
null,
)
if (!queued) return false
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
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)
}
private fun dispatchCenterTap(bounds: Rect): Boolean {
@@ -0,0 +1,8 @@
package cn.ilapage.goauto.agent.automation
internal object PddLaunchFallback {
fun open(preferDirect: Boolean, direct: () -> Boolean, browser: () -> Boolean): Boolean {
if (preferDirect && runCatching(direct).getOrDefault(false)) return true
return runCatching(browser).getOrDefault(false)
}
}
@@ -31,11 +31,21 @@ object PddPageClassifier {
}
class PddLinkLauncher(private val context: Context) {
fun open(url: String): Boolean {
fun open(url: String, preferDirect: Boolean = false): Boolean {
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
if (uri.scheme !in setOf("http", "https") || !isPddHost(uri.host) || uri.getQueryParameter("goods_id").isNullOrBlank()) {
return false
}
return PddLaunchFallback.open(preferDirect, direct = {
val direct = Intent(Intent.ACTION_VIEW, uri)
.setPackage("com.xunmeng.pinduoduo")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(direct)
true
}, browser = { openBrowser(uri) })
}
private fun openBrowser(uri: Uri): Boolean {
val base = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val browser = BROWSER_PACKAGES.firstOrNull { packageName ->
runCatching { context.packageManager.getPackageInfo(packageName, 0) }.isSuccess
@@ -99,6 +99,8 @@ object PddSoldOutRecoveryDefaults {
const val SETTLE_MILLIS = 2_000L
}
data class PurchasePanelContext(val container: SnapshotNode, val exactColor: String)
data class ParsedPddScreen(
val summary: ProductSummary,
val dimensions: List<VisibleDimension>,
@@ -128,6 +130,10 @@ data class ParsedPddScreen(
val bottomPurchaseEntryCount: Int,
val problem: PageProblem?,
val sourceNodes: List<SnapshotNode>,
val isPddPackage: Boolean,
val hasCloseControl: Boolean = false,
val hasPaymentArea: Boolean = false,
val purchaseContextMatched: Boolean = false,
) {
fun isTransientSoldOut(
exactText: String,
@@ -162,6 +168,7 @@ data class ParsedPddScreen(
}
object PddScreenParser {
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
private data class SafeSpecEntry(val anchor: SnapshotNode, val clickTarget: SnapshotNode)
private val pricePattern = Regex("[¥¥]\\s*([0-9]+(?:\\.[0-9]{1,2})?)")
private val salesPattern = Regex("已拼\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*(?:件|人)?")
@@ -179,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?): 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)
@@ -304,9 +311,24 @@ object PddScreenParser {
// Opening evidence is intentionally independent from whether the current
// viewport still exposes a clickable spec value. PDD may hide the only
// selected value or restore a previously scrolled confirmation panel.
val continuedPurchasePanel = snapshot.packageName == PDD_PACKAGE && problem == null &&
purchaseContext != null && headedPanelScrollable != null &&
headedPanelScrollable.path == purchaseContext.container.path &&
headedPanelScrollable.className == purchaseContext.container.className &&
kotlin.math.abs(headedPanelScrollable.bounds.left - purchaseContext.container.bounds.left) <= 32 &&
kotlin.math.abs(headedPanelScrollable.bounds.right - purchaseContext.container.bounds.right) <= 32 &&
minOf(headedPanelScrollable.bounds.bottom, purchaseContext.container.bounds.bottom) >
maxOf(headedPanelScrollable.bounds.top, purchaseContext.container.bounds.top)
val structuredSelectionPanel = headedPanelScrollable != null &&
(headings.size >= 2 || (continuedPurchasePanel && headings.isNotEmpty())) &&
dimensions.any { it.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
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
@@ -320,6 +342,28 @@ object PddScreenParser {
else -> SpecPanelType.UNKNOWN
}
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
val unprefixedSummary = if (structuredSelectionPanel) {
val quantity = quantityInputs.single()
val knownColors = (dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text } +
listOfNotNull(purchaseContext?.exactColor?.takeIf { continuedPurchasePanel && it.isNotBlank() })).distinct()
val byPath = visibleNodes.associateBy { it.path }
var region = quantity.parentPath?.let(byPath::get)
var summary: String? = null
while (region != null && region.bounds.bottom <= headedPanelScrollable!!.bounds.top) {
val candidates = visible.filter { node ->
node.path.startsWith("${region!!.path}/") && !node.clickable &&
node.className?.endsWith("TextView") == true &&
node.bounds.bottom <= quantity.bounds.top && node.label.length <= 160 &&
knownColors.any { SpecValueNormalizer.summaryHasExactToken(node.label, it) }
}.map { it.label }.distinct()
if (candidates.isNotEmpty()) {
summary = candidates.singleOrNull()
break
}
region = region.parentPath?.let(byPath::get)
}
summary
} else null
val firstHeadingTop = headings.firstOrNull()?.bounds?.top ?: Int.MAX_VALUE
val price = visible.asSequence()
.filter { it.bounds.top < firstHeadingTop }
@@ -371,7 +415,7 @@ object PddScreenParser {
selectedSummary = labels.firstOrNull {
val compact = it.replace(" ", "")
textAliases.selection.selectedPrefixes.any(compact::startsWith)
},
} ?: unprefixedSummary,
priceCent = price,
specPanelOpen = panelOpen,
specPanelType = specPanelType,
@@ -390,6 +434,9 @@ object PddScreenParser {
panelHeadingCount = headings.size,
panelOptionCount = dimensions.sumOf { it.values.size },
hasSelectionSummary = hasSelectionSummary,
hasCloseControl = hasClose,
hasPaymentArea = hasPaymentArea,
purchaseContextMatched = continuedPurchasePanel,
hasQuantityControls = hasQuantityControls,
hasOrderSubmitAction = hasOrderSubmitAction,
explicitSpecEntryCount = explicitSpecEntries.size,
@@ -397,6 +444,7 @@ object PddScreenParser {
bottomPurchaseEntryCount = bottomSpecEntries.size,
problem = problem,
sourceNodes = visibleNodes,
isPddPackage = snapshot.packageName == PDD_PACKAGE,
)
}
@@ -34,8 +34,63 @@ class PurchaseLiveAutomation(
private val pause: (Long) -> Unit = Thread::sleep,
) {
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
/**
* Advances an already verified spec selector to the order confirmation
* page. Only the selector's unique exact confirm button is clickable; an
* order-submit or payment control can never satisfy this transition.
*/
fun advanceToOrderConfirmation(allowUnclassifiedPanelWithSelectionProof: Boolean = false) {
var snapshot = driver.capture()
pageProblem(snapshot)
if (orderConfirmationReady(snapshot)) return
if (snapshot.packageName != PDD_PACKAGE) {
fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前不是拼多多规格页面,未创建订单")
}
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
val unclassifiedSelectionPanelWithProof = allowUnclassifiedPanelWithSelectionProof &&
screen.specPanelType == SpecPanelType.UNKNOWN &&
screen.priceCent != null &&
snapshot.nodes.count {
it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label.toLongOrNull() != null
} == 1
if (
screen.specPanelType !in setOf(SpecPanelType.NORMAL_SCROLLABLE, SpecPanelType.NON_SCROLLABLE_CONFIRMATION) &&
!unclassifiedSelectionPanelWithProof
) {
fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前规格面板不能安全确认,未创建订单")
}
val aliases = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR.textAliases.specPanel.confirmAliases
.map { it.replace(" ", "") }
.toSet()
val targets = snapshot.nodes.filter { node ->
node.visible && node.enabled && node.clickable && node.label.replace(" ", "") in aliases
}.distinctBy { it.path }
if (targets.isEmpty()) {
fail("PURCHASE_SPEC_CONFIRM_TARGET_MISSING", "没有找到唯一的规格确认按钮,未创建订单")
}
if (targets.size > 1) {
fail("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", "规格确认按钮不唯一,未创建订单")
}
when (driver.clickFresh(targets.single())) {
FreshActionResult.SUCCESS -> Unit
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", "规格确认按钮不唯一,未创建订单")
else -> fail("PURCHASE_SPEC_CONFIRM_CLICK_FAILED", "规格确认按钮点击失败,未创建订单")
}
repeat(SPEC_CONFIRMATION_MAX_SAMPLES) {
pause(SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS)
snapshot = driver.capture()
pageProblem(snapshot)
if (orderConfirmationReady(snapshot)) return
}
fail("PURCHASE_SPEC_CONFIRMATION_UNCONFIRMED", "规格确认后没有进入订单确认页,未创建订单")
}
fun updateShippingAddress(addressSuffix: String): ShippingAddressProof {
if (!addressSuffix.matches(Regex("^_cg[1-9][0-9]*$"))) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "采购任务的地址标记无效")
var snapshot = driver.capture()
@@ -43,12 +98,31 @@ class PurchaseLiveAutomation(
var unchangedCount = 0
for (attempt in 0 until 5) {
pageProblem(snapshot)
val entries = mergedDirect(snapshot.nodes.filter { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) })
if (entries.size == 1) {
when (driver.tapPurchaseFresh(entries.single())) {
val resolution = resolveAddressEntries(snapshot)
if (resolution.addressCardCount > 1 || resolution.tapTargetCount > 1) {
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${resolution.diagnostic()}]")
}
if (resolution.target != null) {
pause(ADDRESS_ENTRY_STABLE_INTERVAL_MS)
val stableSnapshot = driver.capture()
pageProblem(stableSnapshot)
val stableResolution = resolveAddressEntries(stableSnapshot)
if (stableResolution.addressCardCount > 1 || stableResolution.tapTargetCount > 1) {
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${stableResolution.diagnostic()}]")
}
if (stableResolution.target == null || stableResolution.fingerprint() != resolution.fingerprint()) {
snapshot = stableSnapshot
continue
}
val target = stableResolution.target
val result = when (target.activation) {
AddressEntryActivation.CLICK -> driver.clickAddressEntryFresh(target.node).result
AddressEntryActivation.TAP -> driver.tapPurchaseFresh(target.node)
}
when (result) {
FreshActionResult.SUCCESS -> Unit
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
else -> fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口点击失败,未创建订单")
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${stableResolution.diagnostic()}]")
else -> fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口点击失败,未创建订单 [${stableResolution.diagnostic()}]")
}
pause(1_000)
snapshot = waitFor("PURCHASE_ADDRESS_PANEL_TIMEOUT", "收货地址页面打开超时,未创建订单") {
@@ -56,7 +130,9 @@ class PurchaseLiveAutomation(
}
return editAndVerifyAddress(snapshot, addressSuffix)
}
if (entries.size > 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
if (resolution.phoneNodeCount > 0) {
fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口无法安全定位,未创建订单 [${resolution.diagnostic()}]")
}
val panel = purchasePanelScrollTargets(snapshot)
if (panel.size != 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "没有找到唯一的规格面板滚动区域,未创建订单")
val signature = viewportSignature(snapshot, panel.single())
@@ -72,11 +148,167 @@ class PurchaseLiveAutomation(
fail("PURCHASE_ADDRESS_PANEL_TIMEOUT", "规格面板下拉后仍未找到收货地址,未创建订单")
}
private enum class AddressEntryActivation { CLICK, TAP }
private data class AddressEntryTarget(
val node: SnapshotNode,
val activation: AddressEntryActivation,
val cardKey: String,
)
private data class AddressCardCandidate(
val phone: SnapshotNode,
val clickableCard: SnapshotNode?,
val semanticKey: String?,
)
private data class AddressEntryResolution(
val phoneNodeCount: Int,
val addressCardCount: Int,
val tapTargetCount: Int,
val target: AddressEntryTarget?,
) {
fun diagnostic(): String =
"phoneNodes=$phoneNodeCount;addressCards=$addressCardCount;tapTargets=$tapTargetCount"
fun fingerprint(): String? = target?.let {
listOf(
it.activation.name,
it.cardKey,
it.node.label,
it.node.className.orEmpty(),
it.node.bounds.left,
it.node.bounds.top,
it.node.bounds.right,
it.node.bounds.bottom,
).joinToString(":")
}
}
private fun resolveAddressEntries(snapshot: UiSnapshot): AddressEntryResolution {
val phones = snapshot.nodes.filter {
it.visible && it.enabled && maskedPhone(it.label) != null
}.distinctBy { it.path }
if (phones.isEmpty()) return AddressEntryResolution(0, 0, 0, null)
val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
val candidates = phones.map { phone ->
val card = nearestClickableAncestor(phone, byPath)
AddressCardCandidate(phone, card, card?.let { addressCardSemanticKey(it, snapshot) })
}
val groups = mutableListOf<MutableList<AddressCardCandidate>>()
candidates.forEach { candidate ->
val matching = groups.filter { group -> group.any { sameLogicalAddress(it, candidate) } }
if (matching.isEmpty()) {
groups += mutableListOf(candidate)
} else {
val primary = matching.first()
primary += candidate
matching.drop(1).forEach { duplicate ->
primary += duplicate
groups.remove(duplicate)
}
}
}
val targets = groups.mapIndexed { index, group ->
val clickable = group.filter { it.clickableCard != null }
.minWithOrNull(compareBy<AddressCardCandidate>({ nodeArea(requireNotNull(it.clickableCard)) }, { it.phone.path }))
if (clickable != null) {
AddressEntryTarget(clickable.phone, AddressEntryActivation.CLICK, logicalAddressKey(group, index))
} else {
AddressEntryTarget(group.minBy { nodeArea(it.phone) }.phone, AddressEntryActivation.TAP, logicalAddressKey(group, index))
}
}
val actionable = targets.filter { it.node.bounds.width >= 2 && it.node.bounds.height >= 2 }
return AddressEntryResolution(
phoneNodeCount = phones.size,
addressCardCount = targets.size,
tapTargetCount = actionable.size,
target = actionable.singleOrNull(),
)
}
private fun nearestClickableAncestor(
source: SnapshotNode,
byPath: Map<String, SnapshotNode>,
): SnapshotNode? {
var current: SnapshotNode? = source
while (current != null) {
if (current.clickable && current.visible && current.enabled) return current
current = current.parentPath?.let(byPath::get)
}
return null
}
private fun addressCardSemanticKey(card: SnapshotNode, snapshot: UiSnapshot): String? {
val labels = snapshot.nodes.asSequence()
.filter { it.visible && (it.path == card.path || it.path.startsWith("${card.path}/")) }
.map { normalizeAddressSemantic(it.label) }
.filter { it.isNotBlank() && maskedPhone(it) == null && it !in ADDRESS_CARD_GENERIC_LABELS }
.distinct()
.sorted()
.toList()
if (labels.none { it.length >= ADDRESS_SEMANTIC_MIN_LENGTH }) return null
return labels.joinToString("|")
}
private fun normalizeAddressSemantic(value: String): String = value
.replace(Regex("\\s+"), "")
.replace(",", ",")
.replace(":", ":")
.lowercase()
private fun sameLogicalAddress(first: AddressCardCandidate, second: AddressCardCandidate): Boolean {
if (maskedPhone(first.phone.label) != maskedPhone(second.phone.label)) return false
if (first.clickableCard?.path != null && first.clickableCard.path == second.clickableCard?.path) return true
if (sameAddressRegion(first.phone, second.phone)) return true
val firstCard = first.clickableCard
val secondCard = second.clickableCard
if (firstCard != null && secondCard != null && boundsRepresentSameRegion(firstCard.bounds, secondCard.bounds)) return true
return first.semanticKey != null && first.semanticKey == second.semanticKey
}
private fun boundsRepresentSameRegion(first: NodeBounds, second: NodeBounds): Boolean {
val overlapWidth = (minOf(first.right, second.right) - maxOf(first.left, second.left)).coerceAtLeast(0)
val overlapHeight = (minOf(first.bottom, second.bottom) - maxOf(first.top, second.top)).coerceAtLeast(0)
val overlap = overlapWidth.toLong() * overlapHeight
val smaller = minOf(first.width.toLong() * first.height, second.width.toLong() * second.height)
return smaller > 0 && overlap * 100 >= smaller * ADDRESS_CARD_OVERLAP_PERCENT
}
private fun logicalAddressKey(group: List<AddressCardCandidate>, index: Int): String {
val semantic = group.mapNotNull(AddressCardCandidate::semanticKey).distinct().singleOrNull()
return semantic?.let { "semantic:$it" } ?: "geometry:$index:${group.minOf { it.phone.bounds.top }}"
}
private fun sameAddressRegion(first: SnapshotNode, second: SnapshotNode): Boolean {
if (maskedPhone(first.label) != maskedPhone(second.label)) return false
val overlapWidth = (minOf(first.bounds.right, second.bounds.right) - maxOf(first.bounds.left, second.bounds.left)).coerceAtLeast(0)
val overlapHeight = (minOf(first.bounds.bottom, second.bounds.bottom) - maxOf(first.bounds.top, second.bounds.top)).coerceAtLeast(0)
val overlap = overlapWidth.toLong() * overlapHeight
val smaller = minOf(nodeArea(first), nodeArea(second))
val containsCenter = (
first.bounds.centerX in second.bounds.left..second.bounds.right &&
first.bounds.centerY in second.bounds.top..second.bounds.bottom
) || (
second.bounds.centerX in first.bounds.left..first.bounds.right &&
second.bounds.centerY in first.bounds.top..first.bounds.bottom
)
return (smaller > 0 && overlap * 100 >= smaller * 50) || containsCenter
}
private fun maskedPhone(value: String): String? = MASKED_PHONE.find(value)?.value
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 (!hasFinalSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
if (!hasSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址保存后复核失败,未创建订单")
}
val submit = finalSubmitTargets(snapshot)
@@ -90,6 +322,7 @@ 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())
}
@@ -101,6 +334,18 @@ 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
@@ -238,6 +483,9 @@ 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(), "修改地址")
@@ -257,14 +505,73 @@ class PurchaseLiveAutomation(
val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" })
if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单")
click(save.single(), "保存地址")
val savedEvidence = waitForStableAddressEditorExit()
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix)
var savedEvidence = waitForStableAddressEditorExit()
if (!savedAddressPageReady(savedEvidence, expected, suffix)) {
if (isPurchaseConfirmationPanel(savedEvidence)) {
savedEvidence = restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
} else {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
val returned = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
savedAddressPageReady(it, expected, suffix) || isPurchaseConfirmationPanel(it)
}
savedEvidence = if (!savedAddressPageReady(returned, expected, suffix)) {
restoreFinalEvidenceInCurrentPanel(returned, expected, suffix)
} else returned
}
}
return ShippingAddressProof(expected, suffix)
val proof = ShippingAddressProof(expected, suffix)
savedAddressProof = proof
savedAddressActivity = savedEvidence.activityName
if (finalSubmitTargets(savedEvidence).size != 1) {
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "地址已保存,但未能识别唯一的下单按钮,未创建订单")
}
return proof
}
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
return screen.specPanelType in setOf(
SpecPanelType.NORMAL_SCROLLABLE,
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
SpecPanelType.ORDER_CONFIRMATION,
)
}
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 (!isPurchaseConfirmationPanel(snapshot)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后采购面板发生变化,未创建订单")
}
val panels = savedAddressPanelScrollTargets(snapshot)
if (panels.size != 1) {
val reason = if (panels.isEmpty()) "未找到可用的规格面板滚动区域" else "找到多个规格面板滚动区域"
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后${reason},未创建订单")
}
val panel = panels.single()
val signature = viewportSignature(snapshot, panel)
if (signature == previousSignature) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
}
previousSignature = signature
if (!driver.swipePurchaseIn(panel, SwipeDirection.DOWN, 350)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板无法定位订单确认信息,未创建订单")
}
if (attempt < ADDRESS_CONFIRMATION_SCROLL_LIMIT - 1) pause(ADDRESS_CONFIRMATION_SCROLL_INTERVAL_MS)
snapshot = driver.capture()
pageProblem(snapshot)
}
if (!savedAddressPageReady(snapshot, expected, suffix)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
}
return snapshot
}
private fun hasSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean {
@@ -281,8 +588,9 @@ class PurchaseLiveAutomation(
return suffixMatches.size == 1
}
private fun hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).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 waitForStableAddressEditorExit(): UiSnapshot {
var consecutiveExits = 0
@@ -315,6 +623,20 @@ 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
@@ -329,7 +651,7 @@ class PurchaseLiveAutomation(
private fun viewportSignature(snapshot: UiSnapshot, panel: SnapshotNode): String = snapshot.nodes
.filter { node -> node.visible && inside(node.bounds, panel.bounds) }
.joinToString("|") { node ->
listOf(node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable).joinToString(":")
listOf(node.path, node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable).joinToString(":")
}
private fun inside(child: NodeBounds, parent: NodeBounds): Boolean =
@@ -402,11 +724,91 @@ class PurchaseLiveAutomation(
pause(500)
}
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
private fun legacySubmitTargets(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
return PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null).specPanelType ==
SpecPanelType.ORDER_CONFIRMATION
}
/** A post-submit navigation target is allowed only when it is one exact, non-payment PDD order-detail entry. */
private fun orderDetailEntryTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
snapshot,
@@ -463,6 +865,9 @@ 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("查看订单", "订单详情")
@@ -483,5 +888,13 @@ class PurchaseLiveAutomation(
const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 3
const val ORDER_RESULT_SCROLL_SAMPLE_INTERVAL = 15
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
const val SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS = 100L
const val ADDRESS_ENTRY_STABLE_INTERVAL_MS = 200L
const val ADDRESS_CARD_OVERLAP_PERCENT = 70
const val ADDRESS_SEMANTIC_MIN_LENGTH = 4
val ADDRESS_CARD_GENERIC_LABELS = setOf("收货地址", "修改", "默认地址", "默认")
const val ADDRESS_CONFIRMATION_SCROLL_LIMIT = 5
const val ADDRESS_CONFIRMATION_SCROLL_INTERVAL_MS = 500L
}
}
@@ -3,6 +3,11 @@ 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
@@ -10,6 +15,13 @@ interface PurchaseUiDriver {
result = clickFresh(target),
reason = FreshClickReason.UNKNOWN,
)
/**
* Reacquires one address-entry node by its accessibility path and validates
* its immutable snapshot traits before clicking its nearest clickable
* ancestor. This is intentionally limited to the reversible transition
* from order confirmation into address management.
*/
fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome = clickFreshDetailed(target)
fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult
/**
* Reacquires and center-taps a target that the purchase parser has already
@@ -17,8 +29,15 @@ interface PurchaseUiDriver {
* must still verify the semantic postcondition from a fresh snapshot.
*/
fun tapSpecFresh(target: SnapshotNode): FreshActionResult = FreshActionResult.FAILED
/**
* Reacquires a parsed spec option and swipes only its nearest horizontal
* scrollable ancestor. There is deliberately no global fallback.
*/
fun swipeSpecRow(target: SnapshotNode, direction: SwipeDirection): Boolean = false
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
@@ -75,7 +94,12 @@ class PurchaseRehearsalExecutor(
private val panelDiagnostic: (String) -> Unit = {},
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
) {
private var purchasePanelContext: PurchasePanelContext? = null
private var pageIdentity: Pair<String?, String?> = null to null
private var entryRecoveryReason = "not_checked"
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
purchasePanelContext = null
validateBeforeDeviceAction(input, rule, supportedCapabilities)?.let { return it }
var observedPrice: Long? = null
var addressProof: ShippingAddressProof? = null
@@ -83,6 +107,15 @@ class PurchaseRehearsalExecutor(
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
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
// unrelated screen), so only skip navigation when a fresh snapshot
// still carries safe PDD product/spec evidence.
if (
input.phase == "purchase" &&
action.type == PurchaseActionType.OPEN_PRODUCT &&
canReuseCurrentProduct(input)
) continue
stepChanged(action.type.wireName)
val failure = when (action.type) {
PurchaseActionType.OPEN_PRODUCT -> openProduct(input, action)
@@ -101,6 +134,7 @@ class PurchaseRehearsalExecutor(
null
}
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try {
live.advanceToOrderConfirmation(hasAllRecordedSelectionProofs(input, specSelectionProofs))
addressProof = live.updateShippingAddress(input.addressSuffix)
null
} catch (error: PurchaseLiveException) {
@@ -152,6 +186,11 @@ class PurchaseRehearsalExecutor(
else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作")
}
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean =
currentScreen(input).let { screen ->
screen.problem == null && screen.hasPurchaseProductEvidence()
}
private fun validateBeforeDeviceAction(
input: PurchaseExecutionInput,
rule: PurchaseRule,
@@ -203,15 +242,26 @@ class PurchaseRehearsalExecutor(
}
private fun openProduct(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
purchasePanelContext = null
if (!openLink(input.url)) return failure("PDD_LINK_INVALID", "任务中的 PDD 链接无法打开")
val aliases = action.textAliases ?: listOf("打开拼多多APP", "打开拼多多 App", "打开")
var clickAttempted = false
var nextClickPoll = 0
var lastClickReason = FreshClickReason.UNKNOWN
var stableEvidenceReads = 0
var pddForegroundObserved = false
repeat(OPEN_PRODUCT_POLL_LIMIT) { poll ->
val snapshot = driver.capture()
pageProblem(snapshot)?.let { return it }
if (snapshot.packageName == PDD_PACKAGE) return null
if (snapshot.packageName == PDD_PACKAGE) {
pddForegroundObserved = true
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
stableEvidenceReads = if (screen.hasPurchaseProductEvidence()) stableEvidenceReads + 1 else 0
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) return null
pause(OPEN_PRODUCT_POLL_MILLIS)
return@repeat
}
stableEvidenceReads = 0
val candidates = snapshot.nodes.filter { it.visible && it.enabled && it.label in aliases }
if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一")
if (candidates.size == 1 && poll >= nextClickPoll) {
@@ -229,6 +279,9 @@ class PurchaseRehearsalExecutor(
}
pause(OPEN_PRODUCT_POLL_MILLIS)
}
if (pddForegroundObserved) {
return failure("PDD_DETAIL_ENTRY_FAILED", "打开拼多多后未识别到稳定商品页面")
}
if (clickAttempted) {
val message = when (lastClickReason) {
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> "打开拼多多入口发生变化"
@@ -242,14 +295,20 @@ class PurchaseRehearsalExecutor(
}
private fun verifyProduct(input: PurchaseExecutionInput): PurchaseExecutionOutcome? {
repeat(50) {
var stableEvidenceReads = 0
repeat(PRODUCT_PAGE_POLL_LIMIT) {
val snapshot = driver.capture()
pageProblem(snapshot)?.let { return it }
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
if (screen.hasPurchaseProductEvidence()) {
return recoverSoldOut(input, screen)
stableEvidenceReads++
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) {
return recoverSoldOut(input, screen)
}
} else {
stableEvidenceReads = 0
}
pause(100)
pause(OPEN_PRODUCT_POLL_MILLIS)
}
return failure("PDD_DETAIL_ENTRY_FAILED", "没有进入拼多多商品页面")
}
@@ -319,7 +378,9 @@ class PurchaseRehearsalExecutor(
screen = currentScreen(input)
}
val beforeSignature = specActionSignature(screen)
val beforeIdentity = pageIdentity
val click = driver.clickFreshDetailed(requireNotNull(target))
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
@@ -334,25 +395,37 @@ class PurchaseRehearsalExecutor(
var wait = waitForSpecPanel(input, beforeSignature)
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed) {
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)}")
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
}
when (driver.tapSpecFresh(requireNotNull(target))) {
val gesture = driver.tapSpecFresh(requireNotNull(recoveryTarget))
panelDiagnostic("entryRecovery=attempted;gesture=$gesture;pageChanged=${wait.changed}")
when (gesture) {
FreshActionResult.AMBIGUOUS -> return failure(
SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
)
FreshActionResult.SUCCESS -> Unit
else -> return failure(SPEC_ENTRY_CLICK_FAILED, click.reason.specEntrySubreason())
else -> return failure(
SPEC_ENTRY_CLICK_FAILED,
click.reason.specEntrySubreasonAfterGestureFailure(),
)
}
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed) {
if (wait.changed && recoverableSpecEntry(screen, wait.screen, action, beforeIdentity) == null) {
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(wait.screen)}")
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
@@ -364,6 +437,48 @@ 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"
after.hasPaymentArea -> "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,
@@ -386,9 +501,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) {
repeat(SPEC_POST_CLICK_VERIFY_POLLS) { poll ->
last = currentScreen(input)
panelDiagnostic(panelEvidence(last))
panelDiagnostic("${panelEvidence(last)};entryWaitPolls=${poll + 1};entryWaitMillis=${poll * SPEC_SELECTION_POLL_MILLIS};closeControl=${last.hasCloseControl};paymentArea=${last.hasPaymentArea}")
if (last.reviewPageOpen) {
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
}
@@ -454,6 +569,12 @@ class PurchaseRehearsalExecutor(
else -> "unknown"
}
private fun FreshClickReason.specEntrySubreasonAfterGestureFailure(): String = when (this) {
FreshClickReason.SUCCESS -> "gesture_failed_after_action_click_no_effect"
FreshClickReason.UNKNOWN -> "gesture_failed_after_unclassified_action_result"
else -> specEntrySubreason()
}
private fun selectSpecs(
input: PurchaseExecutionInput,
rule: PurchaseRule,
@@ -483,6 +604,7 @@ 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)
@@ -557,10 +679,18 @@ 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 true
if (screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target)) return true
return summarySelectionMatches(screen.selectedSummary, dimension, target, candidates)
}
private fun fullSummaryTargetMatches(summary: String?, target: String): Boolean =
summary != null && Regex("(^|[\\s,,、/|;;::])" + Regex.escape(target) + "($|[\\s,,、/|;;::])")
.containsMatchIn(summary)
private fun summarySelectionMatches(
summary: String?,
dimension: String,
@@ -575,7 +705,11 @@ class PurchaseRehearsalExecutor(
return matchingCandidates.size == 1 && matchingCandidates.single() == target
}
private data class ExactSpecSelectionProof(val dimension: String, val target: String)
private data class ExactSpecSelectionProof(
val dimension: String,
val target: String,
val panelType: SpecPanelType,
)
private fun exactSpecSelectionProof(
screen: ParsedPddScreen,
@@ -587,8 +721,9 @@ class PurchaseRehearsalExecutor(
val tokenCandidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { candidate ->
if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token
}
return ExactSpecSelectionProof(dimension, target).takeIf {
tokenCandidates.size == 1 && tokenCandidates.single().text == target
return ExactSpecSelectionProof(dimension, target, screen.specPanelType).takeIf {
(tokenCandidates.size == 1 && tokenCandidates.single().text == target) ||
(tokenCandidates.isEmpty() && screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target))
}
}
@@ -597,7 +732,11 @@ class PurchaseRehearsalExecutor(
val reason: String,
val summaryTokenMatched: Boolean,
val candidateCount: Int,
val proofPresent: Boolean,
val proofRecorded: Boolean,
val proofUsable: Boolean,
val targetMatchCount: Int = 0,
val relocationAttempted: Boolean = false,
val relocationSwipes: Int = 0,
)
private fun verifyExactSpecSelection(
@@ -608,15 +747,25 @@ class PurchaseRehearsalExecutor(
): FinalSpecVerification {
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
val matchingNodes = candidates.filter { it.text == target }
val proofPresent = proof?.dimension == dimension && proof.target == target
val proofRecorded = proof?.dimension == dimension && proof.target == target
val proofUsable = proofRecorded && (
proof?.panelType == screen.specPanelType ||
(screen.specPanelType == SpecPanelType.UNKNOWN && screen.isPddPackage && screen.rootAvailable)
)
if (matchingNodes.size == 1 && (matchingNodes.single().node.selected || matchingNodes.single().node.checked)) {
return FinalSpecVerification(true, "selected_node", false, candidates.size, proofPresent)
return FinalSpecVerification(true, "selected_node", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
}
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) {
return FinalSpecVerification(false, "visible_selected_conflict", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
}
val token = if (dimension == "size") SpecValueNormalizer.primarySizeToken(target) else target.trim()
val summaryTokenMatched = !token.isNullOrBlank() && SpecValueNormalizer.summaryHasExactToken(screen.selectedSummary, token)
if (!summaryTokenMatched) {
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofPresent)
if (screen.selectedSummary == null && proofUsable) {
return FinalSpecVerification(true, "attempt_selection_state", false, candidates.size, true, true, matchingNodes.size)
}
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
}
// PDD can rerender or scroll the previously selected dimension out of
// the current viewport while the selected summary remains visible. A
@@ -627,20 +776,97 @@ class PurchaseRehearsalExecutor(
if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token
}
if (visibleTokenMatches.size == 1 && visibleTokenMatches.single().text == target) {
return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofPresent)
return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
}
if (visibleTokenMatches.isNotEmpty()) {
return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofPresent)
return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
}
return if (proofPresent) {
FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true)
return if (proofUsable) {
FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true, true, matchingNodes.size)
} else {
FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, false)
FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, proofRecorded, false, matchingNodes.size)
}
}
private fun verifyExactSpecSelectionWithRelocation(
input: PurchaseExecutionInput,
dimension: String,
target: String,
proof: ExactSpecSelectionProof?,
): FinalSpecVerification {
var screen = currentScreen(input)
screen.problem?.let {
return FinalSpecVerification(false, it.code, false, 0, proof != null, false)
}
var verification = verifyExactSpecSelection(screen, dimension, target, proof)
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
if (!screen.specPanelOpen) return verification.copy(reason = "relocation_panel_closed", relocationAttempted = true)
var signature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
}
var swipes = 0
for ((direction, limit) in listOf(SwipeDirection.DOWN to 3, SwipeDirection.UP to 6)) {
for (attempt in 0 until limit) {
val container = screen.specPanelContainer
?: return verification.copy(
reason = "relocation_container_missing",
relocationAttempted = true,
relocationSwipes = swipes,
)
if (!driver.swipePurchaseIn(container, direction, 350)) {
return verification.copy(
reason = "relocation_swipe_failed",
relocationAttempted = true,
relocationSwipes = swipes,
)
}
swipes++
pause(300)
screen = currentScreen(input)
screen.problem?.let {
return verification.copy(
reason = it.code,
relocationAttempted = true,
relocationSwipes = swipes,
)
}
if (!screen.specPanelOpen) {
return verification.copy(
reason = "relocation_panel_closed",
relocationAttempted = true,
relocationSwipes = swipes,
)
}
verification = verifyExactSpecSelection(screen, dimension, target, proof).copy(
relocationAttempted = true,
relocationSwipes = swipes,
)
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
val refreshedSignature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
}
if (refreshedSignature == signature) break
signature = refreshedSignature
}
}
return verification.copy(relocationAttempted = true, relocationSwipes = swipes)
}
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
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
@@ -648,13 +874,27 @@ class PurchaseRehearsalExecutor(
* Every gesture is followed by a fresh parse, and unchanged evidence ends
* that direction early.
*/
private fun panelRecognitionFailure(screen: ParsedPddScreen, dimension: String): PurchaseExecutionOutcome =
failure("RULE_NOT_MATCHED",
"未能识别当前商品规格面板 [dimension=$dimension;panel=${screen.specPanelType};headings=${screen.panelHeadingCount};options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};orderAction=${screen.hasOrderSubmitAction};close=${screen.hasCloseControl};payment=${screen.hasPaymentArea};contextMatched=${screen.purchaseContextMatched}]")
private fun stableSelectionScreen(input: PurchaseExecutionInput): ParsedPddScreen {
var screen = currentScreen(input)
repeat(2) {
if (screen.specPanelOpen || screen.problem != null) return screen
pause(SPEC_SELECTION_POLL_MILLIS)
screen = currentScreen(input)
}
return screen
}
private fun locateExactSpec(input: PurchaseExecutionInput, dimension: String, target: String): SpecLookup {
data class Inspection(val lookup: SpecLookup?, val signature: String, val container: SnapshotNode?)
fun inspect(): Inspection {
val screen = currentScreen(input)
val screen = stableSelectionScreen(input)
screen.problem?.let { return Inspection(SpecLookup(failure = failure(it.code, it.message)), "", null) }
if (!screen.specPanelOpen) {
return Inspection(SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")), "", null)
return Inspection(SpecLookup(failure = panelRecognitionFailure(screen, dimension)), "", null)
}
val dimensionValues = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
val exact = dimensionValues.filter { it.text == target }
@@ -663,7 +903,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 = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")), "", screen.specPanelContainer)
return Inspection(SpecLookup(failure = unavailableSpec(dimension)), "", screen.specPanelContainer)
}
val signature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
@@ -685,9 +925,171 @@ class PurchaseRehearsalExecutor(
currentSignature = inspected.signature
}
}
if (dimension == "color" || dimension == "size") {
locateExactDimensionHorizontally(input, dimension, target)?.let { return it }
}
return SpecLookup(failure = failure(SPEC_TARGET_NOT_VISIBLE, "有界搜索后未找到精确规格"))
}
/**
* Compatibility fallback for horizontally paged PDD color or size rows. The
* established visible/vertical lookup above remains the primary path, so
* already successful purchases never enter this branch.
*/
private fun locateExactDimensionHorizontally(
input: PurchaseExecutionInput,
dimension: String,
target: String,
): SpecLookup? {
val swipeLimit = DEFAULT_COLLECTOR.limits.getValue("specHorizontalSwipes")
val stableReadLimit = DEFAULT_COLLECTOR.limits.getValue("stableEdgeReads")
data class ColorInspection(
val lookup: SpecLookup?,
val screen: ParsedPddScreen,
val rows: List<List<VisibleSpecValue>>,
val signature: String,
)
fun inspect(): ColorInspection {
val screen = stableSelectionScreen(input)
screen.problem?.let {
return ColorInspection(SpecLookup(failure = failure(it.code, it.message)), screen, emptyList(), "")
}
if (!screen.specPanelOpen) {
return ColorInspection(
SpecLookup(failure = panelRecognitionFailure(screen, dimension)),
screen,
emptyList(),
"",
)
}
val values = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
val exact = values.filter { it.text == target }
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))
else -> null
}
val rows = specValueRows(values)
val signature = screen.dimensions.flatMap { it.values }.joinToString("|") { value ->
val bounds = value.node.bounds
"${value.text}:${bounds.left},${bounds.top},${bounds.right},${bounds.bottom}:${value.available}"
}
return ColorInspection(lookup, screen, rows, signature)
}
var inspected = inspect()
inspected.lookup?.let { return it }
var verticalSwipes = 0
var horizontalSwipes = 0
var lastHorizontalFailure = "none"
var termination = "budgetExhausted"
val horizontalBudget = mutableMapOf(SwipeDirection.RIGHT to swipeLimit, SwipeDirection.LEFT to swipeLimit)
fun searchVisibleRows(): SpecLookup? {
for (direction in listOf(SwipeDirection.RIGHT, SwipeDirection.LEFT)) {
var stableReads = 0
var previousSignature = inspected.signature
while (horizontalBudget.getValue(direction) > 0) {
// Always reacquire a supported row; never fall back to a stale node.
val anchor = horizontalSpecRow(inspected.screen, inspected.rows)?.firstOrNull()?.node ?: break
horizontalBudget[direction] = horizontalBudget.getValue(direction) - 1
if (!driver.swipeSpecRow(anchor, direction)) {
lastHorizontalFailure = driver.specRowSwipeFailureReason()
// A failed gesture is not evidence that the target is absent.
inspected = inspect()
inspected.lookup?.let { return it }
break
}
horizontalSwipes++
pause(300)
inspected = inspect()
inspected.lookup?.let { return it }
stableReads = if (previousSignature == inspected.signature) stableReads + 1 else 0
previousSignature = inspected.signature
if (stableReads >= stableReadLimit) break
}
}
return null
}
// The original fast path remains first. Recover both ends of a vertical
// grid, inspecting each viewport for the exact target and supported rows.
val verticalLimit = DEFAULT_COLLECTOR.limits.getValue("specVerticalSwipes")
for (direction in listOf(SwipeDirection.DOWN, SwipeDirection.UP)) {
var stableReads = 0
var previousSignature = inspected.signature
for (attempt in 0..verticalLimit) {
searchVisibleRows()?.let { return it }
if (attempt == verticalLimit) {
termination = "budgetExhausted"
break
}
val container = inspected.screen.specPanelContainer
if (container == null) {
termination = "verticalContainerMissing"
break
}
if (!driver.swipePurchaseIn(container, direction, 350)) {
termination = "verticalSwipeFailed"
break
}
verticalSwipes++
pause(300)
inspected = inspect()
inspected.lookup?.let { return it }
stableReads = if (previousSignature == inspected.signature) stableReads + 1 else 0
previousSignature = inspected.signature
if (stableReads >= stableReadLimit) {
searchVisibleRows()?.let { return it }
termination = "stableViewport"
break
}
}
}
return SpecLookup(failure = failure(
SPEC_TARGET_NOT_VISIBLE,
"有界搜索后未找到精确规格 [dimension=$dimension;horizontalSwipes=$horizontalSwipes;verticalRecoverySwipes=$verticalSwipes;visibleCandidates=${inspected.rows.flatten().size};reason=$termination;horizontalFailure=$lastHorizontalFailure]",
))
}
private fun horizontalSpecRow(
screen: ParsedPddScreen,
rows: List<List<VisibleSpecValue>>,
): List<VisibleSpecValue>? = rows.firstOrNull { row ->
row.isNotEmpty() && row.all { value -> hasDedicatedHorizontalAncestor(screen, value.node) }
}
private fun hasDedicatedHorizontalAncestor(screen: ParsedPddScreen, source: SnapshotNode): Boolean {
val byPath = screen.sourceNodes.associateBy(SnapshotNode::path)
var current: SnapshotNode? = source
while (current != null) {
if (current.path == screen.specPanelContainer?.path) return false
if (current.scrollable && current.bounds.width > current.bounds.height) return true
current = current.parentPath?.let(byPath::get)
}
return false
}
private fun specValueRows(values: List<VisibleSpecValue>): List<List<VisibleSpecValue>> {
val sorted = values.sortedWith(compareBy({ it.node.bounds.centerY }, { it.node.bounds.left }))
val rows = mutableListOf<MutableList<VisibleSpecValue>>()
val centers = mutableListOf<Int>()
sorted.forEach { value ->
val tolerance = (value.node.bounds.height / 2).coerceIn(24, 80)
val index = centers.indexOfFirst { kotlin.math.abs(value.node.bounds.centerY - it) <= tolerance }
if (index < 0) {
rows += mutableListOf(value)
centers += value.node.bounds.centerY
} else {
rows[index] += value
centers[index] = rows[index].map { it.node.bounds.centerY }.average().toInt()
}
}
return rows.onEach { row -> row.sortBy { it.node.bounds.left } }
}
private fun setQuantity(quantity: Long): PurchaseExecutionOutcome? {
val snapshot = driver.capture()
pageProblem(snapshot)?.let { return it }
@@ -726,23 +1128,26 @@ class PurchaseRehearsalExecutor(
observedPrice: Long?,
selectionProofs: Map<String, ExactSpecSelectionProof>,
): PurchaseExecutionOutcome? {
val screen = currentScreen(input)
screen.problem?.let { return failure(it.code, it.message) }
val selected = listOf("color" to input.mappedColor, "size" to input.mappedSize).filter { it.second.isNotBlank() }.map { (dimension, rawTarget) ->
dimension to (normalizedTarget(dimension, rawTarget)
?: return failure(SPEC_SAFE_TARGET_MISSING, "下发规格无法安全规范化"))
}
selected.forEach { (dimension, target) ->
val verification = verifyExactSpecSelection(screen, dimension, target, selectionProofs[dimension])
val verification = verifyExactSpecSelectionWithRelocation(input, dimension, target, selectionProofs[dimension])
if (!verification.confirmed) {
val screen = currentScreen(input)
val panel = screen.specPanelType.name.lowercase()
val diagnostic = "dimension=$dimension,reason=${verification.reason},panel=$panel," +
"summary=${screen.selectedSummary != null},tokenMatched=${verification.summaryTokenMatched}," +
"candidates=${verification.candidateCount},proof=${verification.proofPresent}"
"candidates=${verification.candidateCount},targetMatches=${verification.targetMatchCount}," +
"proofRecorded=${verification.proofRecorded},proofUsable=${verification.proofUsable}," +
"relocated=${verification.relocationAttempted},relocationSwipes=${verification.relocationSwipes}"
return failure(SPEC_SELECTION_UNCONFIRMED, "最终规格复核未能确认精确选中状态 [$diagnostic]")
}
}
if (readQuantity() != input.quantity) return failure("PURCHASE_QUANTITY_MISMATCH", "最终数量复核失败")
val screen = currentScreen(input)
screen.problem?.let { return failure(it.code, it.message) }
val price = screen.priceCent ?: observedPrice ?: return failure("RULE_NOT_MATCHED", "最终价格复核失败")
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) {
return failure("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
@@ -750,19 +1155,32 @@ class PurchaseRehearsalExecutor(
return null
}
private fun hasAllRecordedSelectionProofs(
input: PurchaseExecutionInput,
proofs: Map<String, ExactSpecSelectionProof>,
): Boolean {
val required = listOf("color" to input.mappedColor, "size" to input.mappedSize)
.filter { it.second.isNotBlank() }
.map { (dimension, rawTarget) -> dimension to normalizedTarget(dimension, rawTarget) }
return required.isNotEmpty() && required.all { (dimension, target) ->
target != null && proofs[dimension]?.let { it.dimension == dimension && it.target == target } == true
}
}
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
action.swipeAfter?.let { swipe ->
// 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
// 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
}
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)
@@ -784,8 +1202,17 @@ class PurchaseRehearsalExecutor(
return normalized.takeIf(SpecValueNormalizer::isSafeSize)
}
private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen =
PddScreenParser.parse(driver.capture(), DEFAULT_COLLECTOR, input.goodsId, null)
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)
purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) {
screen.specPanelContainer?.let {
PurchasePanelContext(it, normalizedTarget("color", input.mappedColor).orEmpty())
}
} else null
return screen
}
private fun pageProblem(snapshot: UiSnapshot): PurchaseExecutionOutcome? =
PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, snapshot.nodes.filter { it.visible }.map { it.label })
@@ -809,7 +1236,9 @@ class PurchaseRehearsalExecutor(
companion object {
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
private const val OPEN_PRODUCT_POLL_LIMIT = 50
private const val OPEN_PRODUCT_POLL_LIMIT = 150
private const val PRODUCT_PAGE_POLL_LIMIT = 150
private const val PRODUCT_PAGE_STABLE_READS = 2
private const val OPEN_PRODUCT_RETRY_POLLS = 10
private const val OPEN_PRODUCT_POLL_MILLIS = 100L
private const val SPEC_ENTRY_READY_WAIT_POLLS = 20
@@ -0,0 +1,24 @@
package cn.ilapage.goauto.agent.automation
internal data class PurchaseScrollCandidate(
val path: String,
val className: String?,
val bounds: NodeBounds,
)
internal object PurchaseScrollLocator {
fun locate(target: PurchaseScrollCandidate, candidates: List<PurchaseScrollCandidate>): PurchaseScrollCandidate? {
val compatible = candidates.filter {
it.className == target.className && boundsMatch(it.bounds, target.bounds)
}
val samePath = compatible.filter { it.path == target.path }
if (samePath.isNotEmpty()) return samePath.singleOrNull()
return compatible.singleOrNull()
}
private fun boundsMatch(a: NodeBounds, b: NodeBounds): Boolean =
kotlin.math.abs(a.left - b.left) <= 32 &&
kotlin.math.abs(a.top - b.top) <= 32 &&
kotlin.math.abs(a.right - b.right) <= 32 &&
kotlin.math.abs(a.bottom - b.bottom) <= 32
}
@@ -0,0 +1,43 @@
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)
}
}
}
@@ -0,0 +1,92 @@
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")
private val booleans = setOf("panelAlreadyOpen", "reviewPage", "pageEvidence", "pageChanged", "summary", "quantity",
"orderAction", "closeControl", "paymentArea")
private val enums = mapOf(
"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,6 +18,9 @@ import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import cn.ilapage.goauto.agent.BuildConfig
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
@@ -57,6 +60,7 @@ import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.Executors
@@ -90,6 +94,7 @@ 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
@@ -108,6 +113,8 @@ 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 ->
@@ -255,13 +262,20 @@ class AgentForegroundService : Service() {
flushPurchaseOutbox(api, token)
val collectionCooldown = activeCollectionCooldown()
val purchaseTask = api.nextPurchaseTask(token)
when (TaskDispatchPolicy.decide(purchaseTask != null, collectionCooldown != null)) {
when (TaskDispatchPolicy.decide(purchaseTask?.status, collectionCooldown != null)) {
TaskDispatchDecision.RUN_PURCHASE -> {
cancelIdleReturn("收到新的采购任务")
releaseCollectionCooldownWakeLock()
schedulePurchaseTask(api, requireNotNull(purchaseTask), token)
return MANUAL_PURCHASE_TASK
}
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH -> {
val waitingTask = requireNotNull(purchaseTask)
cancelIdleReturn("等待采购规格匹配")
stateStore.update("ONLINE", "采购任务 #${waitingTask.taskId} 正在匹配规格", tokenStored = true)
updateNotification("采购任务 #${waitingTask.taskId} 等待规格匹配")
return MANUAL_PURCHASE_MATCH_PENDING
}
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN -> {
val ticket = requireNotNull(collectionCooldown)
showCollectionCooldown(ticket)
@@ -434,8 +448,10 @@ class AgentForegroundService : Service() {
}
private fun executePurchaseTask(api: AgentApiClient, initial: PurchaseAgentTask, token: String) {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
var resultSafelyStored = false
val lastStep = AtomicReference("started")
try {
val claimed = if (initial.status == "pending") {
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
@@ -468,12 +484,29 @@ 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 recordDiagnostic: (String) -> Unit = { evidence ->
val elapsed = SystemClock.elapsedRealtime() - diagnosticStarted
runCatching { diagnosticExecutor.execute { purchaseDiagnostics.record(diagnosticContext, elapsed, evidence) } }
Unit
}
recordDiagnostic("event=started")
PurchaseRehearsalExecutor(
driver = accessibility,
openLink = { PddLinkLauncher(this).open(it) },
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) },
stepChanged = { step -> purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) },
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
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")
},
beforeOrderSubmit = { evidence ->
val boundaryRequestId = UUID.randomUUID().toString()
val finalEvidence = JSONObject()
@@ -513,6 +546,14 @@ class AgentForegroundService : Service() {
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
resultSafelyStored = true
PurchaseResultBubblePolicy.create(
taskId = task.taskId,
resultType = outcome.resultType,
lastStep = lastStep.get(),
resultMessage = outcome.message,
)?.let { presentation ->
GoAutoAccessibilityService.instance?.showPurchaseResultBubble(presentation)
}
beginIdleReturnCooldown()
flushPurchaseOutbox(api, token)
val message = if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message
@@ -611,6 +652,7 @@ class AgentForegroundService : Service() {
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String,
): TaskExecutionSummary {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
return try {
executeTaskWhileAwake(api, initialTask, token)
@@ -1059,6 +1101,7 @@ class AgentForegroundService : Service() {
const val MANUAL_EMPTY = "empty"
const val MANUAL_COLLECTION_TASK = "collection_task"
const val MANUAL_PURCHASE_TASK = "purchase_task"
const val MANUAL_PURCHASE_MATCH_PENDING = "purchase_match_pending"
const val MANUAL_BUSY = "busy"
const val MANUAL_CONFIG_REQUIRED = "config_required"
const val MANUAL_AUTH_ERROR = "auth_error"
@@ -99,13 +99,15 @@ internal object CollectionCooldownPolicy {
internal enum class TaskDispatchDecision {
RUN_PURCHASE,
WAIT_FOR_PURCHASE_MATCH,
WAIT_FOR_COLLECTION_COOLDOWN,
CHECK_COLLECTION,
}
internal object TaskDispatchPolicy {
fun decide(purchaseAvailable: Boolean, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
purchaseAvailable -> TaskDispatchDecision.RUN_PURCHASE
fun decide(purchaseStatus: String?, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
purchaseStatus == "spec_probe_pending" -> TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH
purchaseStatus != null -> TaskDispatchDecision.RUN_PURCHASE
collectionCooldownActive -> TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN
else -> TaskDispatchDecision.CHECK_COLLECTION
}
@@ -0,0 +1,114 @@
package cn.ilapage.goauto.agent.ui
import android.content.Context
import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.TextView
import cn.ilapage.goauto.agent.R
class PurchaseResultBubbleController(
private val context: Context,
private val canShow: () -> Boolean,
) {
private val mainHandler = Handler(Looper.getMainLooper())
private val windowManager = context.getSystemService(WindowManager::class.java)
private val session = PurchaseResultBubbleSession()
private var bubbleView: View? = null
fun show(presentation: PurchaseResultBubblePresentation) {
val revision = session.replace()
mainHandler.post {
if (!session.isCurrent(revision)) return@post
removeCurrentView()
if (!canShow()) return@post
val view = buildView(presentation)
runCatching { windowManager.addView(view, layoutParams()) }
.onSuccess {
bubbleView = view
mainHandler.postDelayed({
if (session.isCurrent(revision)) {
session.dismiss()
removeCurrentView()
}
}, presentation.durationMillis)
}
}
}
fun dismiss() {
session.dismiss()
mainHandler.post(::removeCurrentView)
}
private fun buildView(presentation: PurchaseResultBubblePresentation): View {
val horizontalPadding = context.dp(16)
val verticalPadding = context.dp(12)
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding)
elevation = context.dp(8).toFloat()
background = GradientDrawable().apply {
cornerRadius = context.dp(16).toFloat()
setColor(context.getColor(if (presentation.isFailure) R.color.purchase_result_failure_background else R.color.purchase_result_success_background))
}
contentDescription = "${presentation.title}。${presentation.message}"
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
addView(TextView(context).apply {
text = presentation.title
setTextColor(context.getColor(R.color.purchase_result_text))
textSize = 14f
setTypeface(typeface, android.graphics.Typeface.BOLD)
maxLines = 2
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
addView(TextView(context).apply {
text = presentation.message
setTextColor(context.getColor(R.color.purchase_result_text_secondary))
textSize = 13f
maxLines = 3
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
topMargin = context.dp(4)
})
}
}
private fun layoutParams(): WindowManager.LayoutParams {
val availableWidth = context.resources.displayMetrics.widthPixels - context.dp(32)
return WindowManager.LayoutParams(
availableWidth.coerceAtMost(context.dp(360)),
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT,
).apply {
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
y = context.statusBarHeight() + context.dp(8)
windowAnimations = android.R.style.Animation_Toast
}
}
private fun removeCurrentView() {
val view = bubbleView ?: return
bubbleView = null
runCatching { windowManager.removeViewImmediate(view) }
}
private fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
@Suppress("DiscouragedApi")
private fun Context.statusBarHeight(): Int {
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else dp(24)
}
}
@@ -0,0 +1,86 @@
package cn.ilapage.goauto.agent.ui
data class PurchaseResultBubblePresentation(
val title: String,
val message: String,
val isFailure: Boolean,
val durationMillis: Long,
)
object PurchaseResultBubblePolicy {
const val SUCCESS_DURATION_MILLIS = 3_000L
const val FAILURE_DURATION_MILLIS = 8_000L
private const val MAX_MESSAGE_LENGTH = 96
private val stepLabels = mapOf(
"started" to "准备采购",
"openProduct" to "打开商品",
"verifyProduct" to "核对商品",
"openSpecPanel" to "打开规格面板",
"selectSpec" to "选择颜色与尺码",
"setQuantity" to "设置数量",
"verifyUnitPrice" to "核对价格",
"verifyOrderSummary" to "核对订单",
"probeSpecs" to "采集规格",
"updateShippingAddress" to "更新收货地址",
"createOrder" to "创建待付款订单",
"order_submit_started" to "创建待付款订单",
"readOrderResult" to "读取订单编号和下单时间",
)
fun create(
taskId: Long,
resultType: String,
lastStep: String?,
resultMessage: String?,
): PurchaseResultBubblePresentation? {
if (resultType == "spec_probe_completed") return null
val failure = resultType == "failed" || resultType == "order_result_unknown"
if (failure) {
val label = stepLabels[lastStep] ?: "处理采购任务"
return PurchaseResultBubblePresentation(
title = if (resultType == "order_result_unknown") "CG-$taskId 待人工核对|$label" else "CG-$taskId 失败于:$label",
message = sanitize(resultMessage, "采购失败,请返回 Agent 查看详情"),
isFailure = true,
durationMillis = FAILURE_DURATION_MILLIS,
)
}
val successMessage = when (resultType) {
"order_created" -> "已获取订单编号和下单时间"
"rehearsal_completed" -> "商品、规格、数量和价格复核完成"
else -> sanitize(resultMessage, "采购结果已安全保存")
}
return PurchaseResultBubblePresentation(
title = "CG-$taskId 采购完成",
message = successMessage,
isFailure = false,
durationMillis = SUCCESS_DURATION_MILLIS,
)
}
private fun sanitize(value: String?, fallback: String): String {
val normalized = value.orEmpty()
.replace(Regex("https?://\\S+", RegexOption.IGNORE_CASE), "[链接已隐藏]")
.replace(Regex("(?i)(token|authorization|cookie)\\s*[:=]\\s*\\S+")) { match ->
"${match.groupValues[1]}=[已隐藏]"
}
.replace(Regex("[\\r\\n\\t]+"), " ")
.replace(Regex("\\s{2,}"), " ")
.trim()
.ifBlank { fallback }
return normalized.take(MAX_MESSAGE_LENGTH)
}
}
class PurchaseResultBubbleSession {
private var revision = 0L
@Synchronized
fun replace(): Long = ++revision
@Synchronized
fun dismiss(): Long = ++revision
@Synchronized
fun isCurrent(candidate: Long): Boolean = candidate == revision
}
@@ -36,8 +36,6 @@ import cn.ilapage.goauto.agent.network.HistoryPage
import cn.ilapage.goauto.agent.network.HistoryColorImage
import cn.ilapage.goauto.agent.network.PurchaseHistoryDetail
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
import cn.ilapage.goauto.agent.network.PurchaseResetResult
import cn.ilapage.goauto.agent.network.PurchaseRetryResult
import cn.ilapage.goauto.agent.persistence.TaskHistoryCache
import cn.ilapage.goauto.agent.service.AgentForegroundService
import cn.ilapage.goauto.agent.service.AgentSettingsStore
@@ -85,10 +83,12 @@ internal object CollectionResetPolicy {
internal object PurchaseRetryPolicy {
fun showsAction(status: String, retryable: Boolean): Boolean = status == "failed" && retryable
fun usesInPlaceReset(continuing: Boolean): Boolean = !continuing
fun confirmationMessage(continuing: Boolean = false): String = if (continuing) {
"替代商品已完成匹配。系统会保留原任务并创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
} else {
"将使用服务端最新采购规则重跑当前任务,任务号和商品、规格、价格快照不变;可能创建拼多多待付款订单,但不会支付。"
"系统会复用原采购任务并开始新一次执行,使用当前有效采购规则,商品和规格等任务快照保持不变;可能创建拼多多待付款订单,但不会支付。"
}
}
@@ -748,7 +748,7 @@ class TaskHistoryFragment : Fragment() {
contentDescription = "重试采购任务 CG-${task.taskId}"
setOnClickListener { confirmPurchaseRetry(task) }
}, collectionCardParams())
resultColumn.addView(context.centeredMessage("重试边界", "复用当前任务并刷新采购规则;不会执行支付。"))
resultColumn.addView(context.centeredMessage("重试边界", "复用当前任务并新增一次执行;使用当前有效采购规则,商品和规格快照不变,不会执行支付。"))
} else if (!replacementInProgress && task.status == "failed") {
val reason = task.retryDisabledReason?.takeIf(String::isNotBlank) ?: "请在管理端核对任务状态。"
resultColumn.addView(context.centeredMessage("不可重试", reason))
@@ -866,22 +866,25 @@ class TaskHistoryFragment : Fragment() {
val generation = ++requestGeneration
showLoading(if (continuing) "正在提交继续采购请求…" else "正在提交重试请求…")
Thread {
runCatching {
runCatching<Unit> {
val client = AgentApiClient(serverUrl)
val requestId = UUID.randomUUID().toString()
if (continuing) client.retryPurchaseTask(taskId, requestId, credentials.token)
else client.resetPurchaseTask(taskId, requestId, credentials.token)
}
.onSuccess { result ->
if (PurchaseRetryPolicy.usesInPlaceReset(continuing)) {
val result = client.resetPurchaseTask(taskId, requestId, credentials.token)
resultColumn.post {
if (!isAdded || generation != requestGeneration) return@post
AgentForegroundService.start(requireContext())
when (result) {
is PurchaseRetryResult -> showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
is PurchaseResetResult -> showPurchaseResetSuccess(result.taskNo, result.taskId, result.attemptNumber)
}
showPurchaseResetSuccess(result.taskNo, result.taskId, result.attemptNumber)
}
} else {
val result = client.retryPurchaseTask(taskId, requestId, credentials.token)
resultColumn.post {
if (!isAdded || generation != requestGeneration) return@post
AgentForegroundService.start(requireContext())
showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
}
}
}
.onFailure { error ->
resultColumn.post {
if (isAdded && generation == requestGeneration) {
@@ -892,14 +895,14 @@ class TaskHistoryFragment : Fragment() {
}.start()
}
private fun showPurchaseRetrySuccess(sourceTaskNo: String, taskNo: String, taskId: Long) {
private fun showPurchaseResetSuccess(taskNo: String, taskId: Long, attemptNumber: Int) {
resultColumn.removeAllViews()
resultColumn.addView(requireContext().centeredMessage(
"已创建新任务 $taskNo",
"旧任务 $sourceTaskNo 已保留。新任务将由当前设备按正常队列执行;系统不会支付。",
"$taskNo 已进入第 $attemptNumber 次执行",
"原采购任务已复用,并将由当前设备按正常队列执行;系统不会支付。",
))
resultColumn.addView(MaterialButton(requireContext()).apply {
text = "查看新任务"
text = "查看当前任务"
minimumHeight = requireContext().dp(48)
setOnClickListener { loadPurchaseDetail(taskId) }
}, collectionCardParams())
@@ -910,14 +913,14 @@ class TaskHistoryFragment : Fragment() {
}, collectionCardParams())
}
private fun showPurchaseResetSuccess(taskNo: String, taskId: Long, attemptNumber: Int) {
private fun showPurchaseRetrySuccess(sourceTaskNo: String, taskNo: String, taskId: Long) {
resultColumn.removeAllViews()
resultColumn.addView(requireContext().centeredMessage(
"已加入重试队列 $taskNo",
"任务号保持不变,将使用最新采购规则进行第 $attemptNumber 次尝试;系统不会支付。",
"已创建新任务 $taskNo",
"旧任务 $sourceTaskNo 已保留。新任务将由当前设备按正常队列执行;系统不会支付。",
))
resultColumn.addView(MaterialButton(requireContext()).apply {
text = "查看当前任务"
text = "查看新任务"
minimumHeight = requireContext().dp(48)
setOnClickListener { loadPurchaseDetail(taskId) }
}, collectionCardParams())
@@ -8,4 +8,8 @@
<color name="agent_text_muted">#CBD5E1</color>
<color name="agent_warning">#FBBF24</color>
<color name="agent_error">#F87171</color>
<color name="purchase_result_success_background">#166534</color>
<color name="purchase_result_failure_background">#991B1B</color>
<color name="purchase_result_text">#FFFFFF</color>
<color name="purchase_result_text_secondary">#F1F5F9</color>
</resources>
@@ -86,15 +86,19 @@ class CollectionCooldownPolicyTest {
fun `purchase keeps priority while collection waits for cooldown`() {
assertEquals(
TaskDispatchDecision.RUN_PURCHASE,
TaskDispatchPolicy.decide(purchaseAvailable = true, collectionCooldownActive = true),
TaskDispatchPolicy.decide(purchaseStatus = "pending", collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH,
TaskDispatchPolicy.decide(purchaseStatus = "spec_probe_pending", collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN,
TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = true),
TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.CHECK_COLLECTION,
TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = false),
TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = false),
)
}
}
@@ -0,0 +1,25 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.PddLaunchFallback
import org.junit.Assert.*
import org.junit.Test
class PddLaunchFallbackTest {
@Test fun directSuccessDoesNotStartBrowser() {
assertTrue(PddLaunchFallback.open(true, { true }, { error("browser must not start") }))
}
@Test fun missingHandlerFallsBackOnce() {
var browserCalls = 0
assertTrue(PddLaunchFallback.open(true, { throw IllegalStateException("no handler") }, { browserCalls++; true }))
assertEquals(1, browserCalls)
}
@Test fun rejectedDirectLaunchFallsBack() {
assertTrue(PddLaunchFallback.open(true, { false }, { true }))
assertFalse(PddLaunchFallback.open(true, { false }, { false }))
}
@Test fun collectionKeepsBrowserEntry() {
var directCalls = 0
assertTrue(PddLaunchFallback.open(false, { directCalls++; true }, { true }))
assertEquals(0, directCalls)
}
}
@@ -11,6 +11,7 @@ import cn.ilapage.goauto.agent.automation.PddCollectorConfig
import cn.ilapage.goauto.agent.automation.PddCollectorDriver
import cn.ilapage.goauto.agent.automation.PddProductDetailCollector
import cn.ilapage.goauto.agent.automation.PddScreenParser
import cn.ilapage.goauto.agent.automation.PurchasePanelContext
import cn.ilapage.goauto.agent.automation.SnapshotNode
import cn.ilapage.goauto.agent.automation.SpecPanelType
import cn.ilapage.goauto.agent.automation.SwipeDirection
@@ -26,6 +27,62 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PddProductDetailCollectorTest {
private fun prefixlessPanel(): UiSnapshot = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
node("root", "", 0, 0, 1080, 2376),
node("close", "关闭", 970, 270, 1050, 350, clickable = true),
node("info", "", 0, 770, 1080, 1157),
node("info/summary", "米白色(有里布) 2XL 建议131到150斤", 396, 878, 1053, 992, parentPath = "info"),
node("info/quantity", "1", 480, 1046, 561, 1121, className = "android.widget.EditText", parentPath = "info"),
node("minus", "减少数量", 396, 1046, 470, 1121, clickable = true),
node("plus", "增加数量", 570, 1046, 645, 1121, clickable = true),
node("scroll", "", 0, 1294, 1080, 2079, scrollable = true),
node("scroll/color-title", "颜色分类", 36, 1319, 216, 1380, parentPath = "scroll"),
node("scroll/color", "米白色(有里布)", 36, 1405, 352, 1808, clickable = true, selected = true, parentPath = "scroll"),
node("scroll/size-title", "尺码", 36, 1858, 126, 1911, parentPath = "scroll"),
node("scroll/size", "XL 建议111到130斤", 36, 1930, 440, 2015, clickable = true, parentPath = "scroll"),
node("payment", "微信支付", 112, 2100, 929, 2157),
node("submit", "提交订单", 375, 2225, 705, 2284, clickable = true),
))
@Test fun `prefixless selected panel retains structural recognition and product summary`() {
val parsed = PddScreenParser.parse(prefixlessPanel(), config(), GOODS_ID, evidence())
assertEquals(SpecPanelType.NORMAL_SCROLLABLE, parsed.specPanelType)
assertEquals("米白色(有里布) 2XL 建议131到150斤", parsed.selectedSummary)
}
@Test fun `prefixless panel requires quantity and order evidence`() {
for (missing in listOf("plus", "submit", "payment")) {
val snapshot = prefixlessPanel()
val parsed = PddScreenParser.parse(snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == missing }), config(), GOODS_ID, evidence())
assertFalse(parsed.specPanelOpen)
assertEquals(null, parsed.selectedSummary)
}
}
@Test fun `ambiguous product summaries are not used`() {
val snapshot = prefixlessPanel()
val extra = node("info/other", "米白色(有里布) XL 建议111到130斤", 396, 800, 1053, 870, parentPath = "info")
val parsed = PddScreenParser.parse(snapshot.copy(nodes = snapshot.nodes + extra), config(), GOODS_ID, evidence())
assertEquals(null, parsed.selectedSummary)
}
@Test fun `scrolling away color heading and options preserves known purchase panel and summary`() {
val initial = prefixlessPanel()
val opened = PddScreenParser.parse(initial, config(), GOODS_ID, evidence())
val context = PurchasePanelContext(requireNotNull(opened.specPanelContainer), "米白色(有里布)")
val scrolled = initial.copy(nodes = initial.nodes.filterNot { it.path.startsWith("scroll/color") })
val continued = PddScreenParser.parse(scrolled, config(), GOODS_ID, evidence(), context)
assertEquals(1, continued.panelHeadingCount)
assertTrue(continued.specPanelOpen)
assertEquals("米白色(有里布) 2XL 建议131到150斤", continued.selectedSummary)
assertTrue(continued.dimensions.none { it.key == "color" })
assertFalse(PddScreenParser.parse(scrolled, config(), GOODS_ID, evidence()).specPanelOpen)
assertFalse(PddScreenParser.parse(scrolled, config(), GOODS_ID, evidence(),
context.copy(container = context.container.copy(path = "other"))).specPanelOpen)
assertFalse(PddScreenParser.parse(scrolled.copy(nodes = scrolled.nodes.filterNot { it.path == "submit" }),
config(), GOODS_ID, evidence(), context).specPanelOpen)
}
@Test
fun `parser removes only trailing size price and keeps raw evidence`() {
val snapshot = UiSnapshot(
@@ -1,6 +1,8 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.FreshActionResult
import cn.ilapage.goauto.agent.automation.FreshClickOutcome
import cn.ilapage.goauto.agent.automation.FreshClickReason
import cn.ilapage.goauto.agent.automation.NodeBounds
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
@@ -16,6 +18,236 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseLiveAutomationTest {
@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()
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation()
assertEquals(listOf("确定"), driver.clicked)
assertEquals("order", driver.page)
}
@Test
fun `ambiguous missing and unchanged spec confirmation stop safely`() {
val ambiguous = SpecConfirmationDriver(confirmLabels = listOf("确定", "确认"))
val ambiguousError = runCatching { PurchaseLiveAutomation(ambiguous, pause = {}).advanceToOrderConfirmation() }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", ambiguousError.code)
assertTrue(ambiguous.clicked.isEmpty())
val missing = SpecConfirmationDriver(confirmLabels = emptyList(), specPanelScrollable = true)
val missingError = runCatching { PurchaseLiveAutomation(missing, pause = {}).advanceToOrderConfirmation() }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRM_TARGET_MISSING", missingError.code)
assertTrue(missing.clicked.isEmpty())
val unchanged = SpecConfirmationDriver(advanceAfterClick = false)
val unchangedError = runCatching { PurchaseLiveAutomation(unchanged, pause = {}).advanceToOrderConfirmation() }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRMATION_UNCONFIRMED", unchangedError.code)
assertEquals(listOf("确定"), unchanged.clicked)
}
@Test
fun `existing order confirmation does not click submit or payment controls`() {
val driver = SpecConfirmationDriver(startOnOrderPage = true)
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation()
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `recorded exact selections allow one unique confirm on an unclassified pdd panel`() {
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true)
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation(
allowUnclassifiedPanelWithSelectionProof = true,
)
assertEquals(listOf("确定"), driver.clicked)
assertEquals("order", driver.page)
}
@Test
fun `unclassified pdd panel without recorded selections remains blocked`() {
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation() }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRMATION_NOT_READY", error.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `recorded selections never authorize a generic unclassified pdd dialog`() {
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true, unclassifiedHasPurchaseEvidence = false)
val error = runCatching {
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation(
allowUnclassifiedPanelWithSelectionProof = true,
)
}.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRMATION_NOT_READY", error.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `address is retagged verified and final order button can only be clicked once`() {
val driver = LiveDriver()
@@ -64,6 +296,97 @@ 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)
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg91")
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg91"), address)
assertEquals("_cg91", final.addressSuffix)
assertEquals(0, driver.backCount)
assertEquals(1, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address stuck on spec panel fails without backing out or submitting`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedSpecPanelRecoveryStuck = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg92") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(0, driver.backCount)
assertEquals(0, driver.submitClicks)
}
@Test
fun `edit field containing suffix cannot impersonate post save evidence`() {
val driver = LiveDriver(saveStaysInEdit = true)
@@ -399,6 +722,52 @@ class PurchaseLiveAutomationTest {
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `duplicate masked phone nodes in one clickable address card are activated once`() {
val driver = LiveDriver(duplicatePhoneNodesSameCard = true)
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94")
assertEquals(1, driver.clicked.count { it == "138****5678" })
assertEquals(0, driver.addressTaps)
}
@Test
fun `duplicate semantic address cards in separate accessibility trees are activated once`() {
val driver = LiveDriver(duplicateSemanticAddressCards = true)
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94")
assertEquals(1, driver.addressPathClicks)
assertEquals(1, driver.clicked.count { it == "138****5678" })
assertEquals(0, driver.addressTaps)
}
@Test
fun `two independent address cards remain ambiguous without any click`() {
val driver = LiveDriver(duplicateAddressCards = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg95") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
assertTrue(error.message.orEmpty().contains("phoneNodes=2;addressCards=2;tapTargets=2"))
assertFalse(error.message.orEmpty().contains("138"))
assertTrue(driver.clicked.isEmpty())
assertEquals(0, driver.addressTaps)
}
@Test
fun `address entry path drift fails without fallback tap`() {
val driver = LiveDriver(duplicatePhoneNodesSameCard = true, addressEntryPathUnavailable = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_NOT_READY", error.code)
assertTrue(driver.clicked.isEmpty())
assertEquals(1, driver.addressPathClicks)
assertEquals(0, driver.addressTaps)
}
private fun input() = PurchaseExecutionInput(
taskId = 11,
executionMode = "live",
@@ -413,6 +782,78 @@ class PurchaseLiveAutomationTest {
addressSuffix = "_cg11",
)
private class SpecConfirmationDriver(
private val confirmLabels: List<String> = listOf("确定"),
private val advanceAfterClick: Boolean = true,
private val specPanelScrollable: Boolean = false,
private val unclassifiedSpecPanel: Boolean = false,
private val unclassifiedHasPurchaseEvidence: Boolean = true,
startOnOrderPage: Boolean = false,
) : PurchaseUiDriver {
var page = if (startOnOrderPage) "order" else "spec"
val clicked = mutableListOf<String>()
override fun capture(): UiSnapshot = if (page == "order") {
snapshot(listOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
node("scroll", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
node("summary", "已选 黑色 均码"),
node("quantity", "1", className = "android.widget.EditText"),
node("phone", "138****5678"),
node("submit", "提交订单", clickable = true),
node("payment", "微信支付"),
))
} else {
snapshot(buildList {
add(node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)))
if (unclassifiedSpecPanel) {
if (unclassifiedHasPurchaseEvidence) {
add(node("price", "¥20.00"))
add(node("quantity", "1", className = "android.widget.EditText"))
}
confirmLabels.forEachIndexed { index, label -> add(node("confirm-$index", label, clickable = true)) }
return@buildList
}
if (specPanelScrollable) {
add(node("scroll", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 1500)))
}
add(node("title", "确认款式"))
add(node("summary", "已选 黑色 均码"))
val parent = "scroll".takeIf { specPanelScrollable }
val prefix = if (specPanelScrollable) "scroll/" else ""
add(node("${prefix}color-heading", "颜色分类", parentPath = parent))
add(node("${prefix}color", "黑色", clickable = true, parentPath = parent))
add(node("${prefix}size-heading", "尺码", parentPath = parent))
add(node("${prefix}size", "均码", clickable = true, parentPath = parent))
add(node("quantity", "1", className = "android.widget.EditText"))
confirmLabels.forEachIndexed { index, label -> add(node("confirm-$index", label, clickable = true)) }
})
}
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicked += target.label
if (advanceAfterClick && target.label in confirmLabels) page = "order"
return FreshActionResult.SUCCESS
}
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
override fun backPurchase() = false
private fun snapshot(nodes: List<SnapshotNode>) = UiSnapshot(PDD, ACTIVITY, nodes)
private fun node(
path: String,
text: String,
clickable: Boolean = false,
scrollable: Boolean = false,
className: String = "android.widget.TextView",
bounds: NodeBounds = NodeBounds(20, 100, 900, 180),
parentPath: String? = null,
) = SnapshotNode(path, parentPath, text, null, null, className, bounds, clickable, scrollable, false, false, true, true)
}
private class LiveDriver(
private val addressClipped: Boolean = false,
private val duplicatePanels: Boolean = false,
@@ -424,6 +865,16 @@ class PurchaseLiveAutomationTest {
private val saveStaysInEdit: Boolean = false,
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,
private val addressEntryPathUnavailable: Boolean = false,
private val wechatLoginAfterSubmit: Boolean = false,
private val wechatRestoreStuck: Boolean = false,
private val orderEvidenceBelowFold: Boolean = false,
@@ -442,6 +893,7 @@ class PurchaseLiveAutomationTest {
var scopedSwipes = 0
var genericSwipes = 0
var addressTaps = 0
var addressPathClicks = 0
var lastInputTargetPath: String? = null
var backCount = 0
var postSubmitBackCount = 0
@@ -449,16 +901,33 @@ class PurchaseLiveAutomationTest {
var orderDetailEntryClicks = 0
var postSubmitCaptureCount = 0
val currentPage: String get() = page
var structuredTransform: (UiSnapshot) -> UiSnapshot = { it }
override fun capture(): UiSnapshot {
if (submitClicks > 0) {
postSubmitCaptureCount++
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
}
return currentSnapshot()
return currentSnapshot().let { if (page == "structured") structuredTransform(it) else it }
}
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),
@@ -479,6 +948,22 @@ class PurchaseLiveAutomationTest {
node("transition-title", "选择收货信息"),
node("address-summary", if (savedTransitionHidesSuffix) "已保存的收货信息" else address.substring(address.lastIndexOf("_cg"))),
))
"post-save-spec" -> snapshot(listOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
node("panel", "", scrollable = true, bounds = savedPanelBounds),
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)),
node("panel/color-heading", "颜色分类", parentPath = "panel", bounds = NodeBounds(20, 650, 300, 700)),
node("panel/color", "黑色", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 720, 220, 790)),
node("panel/size-heading", "尺码", parentPath = "panel", bounds = NodeBounds(20, 850, 300, 900)),
node("panel/size", "XL", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 920, 220, 990)),
node("panel/quantity", "2", className = "android.widget.EditText", parentPath = "panel", bounds = NodeBounds(400, 1050, 600, 1120)),
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(
@@ -512,7 +997,30 @@ class PurchaseLiveAutomationTest {
)
if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000))
if (addressVisible) {
nodes += node("phone", "138****5678")
when {
duplicateSemanticAddressCards -> {
nodes += node("address-layer-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 840))
nodes += node("address-layer-a/phone", "138****5678", parentPath = "address-layer-a", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-layer-a/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-a", bounds = NodeBounds(20, 720, 900, 790))
nodes += node("address-layer-b", "", clickable = true, bounds = NodeBounds(0, 900, 1080, 1120))
nodes += node("address-layer-b/phone", "138****5678", parentPath = "address-layer-b", bounds = NodeBounds(20, 930, 400, 990))
nodes += node("address-layer-b/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-b", bounds = NodeBounds(20, 1000, 900, 1070))
}
duplicateAddressCards -> {
nodes += node("address-card-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 820))
nodes += node("address-card-a/phone", "138****5678", parentPath = "address-card-a", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-card-a/detail", "广东省广州市天园街道一号", parentPath = "address-card-a", bounds = NodeBounds(20, 720, 900, 780))
nodes += node("address-card-b", "", clickable = true, bounds = NodeBounds(0, 840, 1080, 1040))
nodes += node("address-card-b/phone", "138****5678", parentPath = "address-card-b", bounds = NodeBounds(20, 870, 400, 930))
nodes += node("address-card-b/detail", "广东省广州市天园街道二号", parentPath = "address-card-b", bounds = NodeBounds(20, 940, 900, 1000))
}
duplicatePhoneNodesSameCard -> {
nodes += node("address-card", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 900))
nodes += node("address-card/phone-a", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-card/phone-b", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 720, 440, 790))
}
else -> nodes += node("phone", "138****5678")
}
val suffixStart = address.lastIndexOf("_cg")
val addressBody = if (suffixStart >= 0) address.substring(0, suffixStart) else address
val addressSuffix = if (suffixStart >= 0) address.substring(suffixStart) else ""
@@ -534,10 +1042,22 @@ 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"
"保存" -> if (!saveStaysInEdit) page = if (savedTransitionWithoutLegacyContext) "saved-transition" else "panel"
"保存" -> if (!saveStaysInEdit) {
page = when {
saveReturnsToSpecPanel -> "post-save-spec"
savedTransitionWithoutLegacyContext -> "saved-transition"
savedStructuredFooter != null -> "structured"
else -> "panel"
}
}
"提交订单" -> {
submitClicks++
page = when {
@@ -555,6 +1075,14 @@ class PurchaseLiveAutomationTest {
return FreshActionResult.SUCCESS
}
override fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome {
addressPathClicks++
if (addressEntryPathUnavailable) {
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
}
return FreshClickOutcome(clickFresh(target), FreshClickReason.SUCCESS, 1, 1)
}
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult {
addressTaps++
clicked += target.label
@@ -575,6 +1103,7 @@ class PurchaseLiveAutomationTest {
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
scopedSwipes++
if (target.path == "panel" && direction == SwipeDirection.DOWN) addressVisible = true
if (page == "post-save-spec" && direction == SwipeDirection.DOWN && !savedSpecPanelRecoveryStuck) page = "confirmation"
return true
}
override fun backPurchase(): Boolean {
@@ -583,6 +1112,7 @@ 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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy
import cn.ilapage.goauto.agent.ui.PurchaseResultBubbleSession
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseResultBubblePolicyTest {
@Test
fun `spec probe does not display a final result bubble`() {
assertNull(PurchaseResultBubblePolicy.create(69, "spec_probe_completed", "probeSpecs", "规格已回传"))
}
@Test
fun `failure includes the real step and sanitized reason for eight seconds`() {
val result = requireNotNull(PurchaseResultBubblePolicy.create(
69,
"failed",
"selectSpec",
"没有找到精确规格\n请重试 token=secret-value",
))
assertEquals("CG-69 失败于:选择颜色与尺码", result.title)
assertEquals("没有找到精确规格 请重试 token=[已隐藏]", result.message)
assertTrue(result.isFailure)
assertEquals(8_000L, result.durationMillis)
}
@Test
fun `unknown order result is presented as a failure requiring manual check`() {
val result = requireNotNull(PurchaseResultBubblePolicy.create(
70,
"order_result_unknown",
"readOrderResult",
"无法确认订单是否创建,请人工检查",
))
assertEquals("CG-70 待人工核对|读取订单编号和下单时间", result.title)
assertTrue(result.isFailure)
assertEquals(8_000L, result.durationMillis)
}
@Test
fun `successful order uses a short factual summary for three seconds`() {
val result = requireNotNull(PurchaseResultBubblePolicy.create(
71,
"order_created",
"readOrderResult",
"ignored",
))
assertEquals("CG-71 采购完成", result.title)
assertEquals("已获取订单编号和下单时间", result.message)
assertFalse(result.isFailure)
assertEquals(3_000L, result.durationMillis)
}
@Test
fun `new result and dismiss invalidate an older scheduled bubble`() {
val session = PurchaseResultBubbleSession()
val first = session.replace()
val second = session.replace()
assertFalse(session.isCurrent(first))
assertTrue(session.isCurrent(second))
session.dismiss()
assertFalse(session.isCurrent(second))
}
}
@@ -16,18 +16,22 @@ class PurchaseRetryPolicyTest {
}
@Test
fun `retry confirmation explains same task latest rule and no payment`() {
fun `ordinary retry uses same task new attempt and no payment`() {
val message = PurchaseRetryPolicy.confirmationMessage()
assertTrue(message.contains("当前任务"))
assertTrue(message.contains("最新采购规则"))
assertFalse(message.contains("新采购任务"))
assertTrue(PurchaseRetryPolicy.usesInPlaceReset(continuing = false))
assertTrue(message.contains("复用原采购任务"))
assertTrue(message.contains("新一次执行"))
assertTrue(message.contains("当前有效采购规则"))
assertTrue(message.contains("任务快照保持不变"))
assertTrue(message.contains("待付款订单"))
assertTrue(message.contains("不会支付"))
assertFalse(message.contains("新采购任务"))
}
@Test
fun `continue confirmation remains a new purchase task`() {
val message = PurchaseRetryPolicy.confirmationMessage(continuing = true)
assertFalse(PurchaseRetryPolicy.usesInPlaceReset(continuing = true))
assertTrue(message.contains("新采购任务"))
assertTrue(message.contains("替代商品"))
assertTrue(message.contains("不会支付"))
@@ -0,0 +1,39 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.NodeBounds
import cn.ilapage.goauto.agent.automation.PurchaseScrollCandidate
import cn.ilapage.goauto.agent.automation.PurchaseScrollLocator
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PurchaseScrollLocatorTest {
private val outer = PurchaseScrollCandidate("0/1", "RecyclerView", NodeBounds(0, 1038, 1080, 2079))
private val inner = PurchaseScrollCandidate("0/1/0", "RecyclerView", NodeBounds(36, 1149, 1080, 2007))
@Test fun nestedContainersWithNearbyCentersResolveOuter() {
assertEquals(outer, PurchaseScrollLocator.locate(outer, listOf(inner, outer)))
}
@Test fun pathDisambiguatesIdenticalBounds() {
val nested = outer.copy(path = "0/1/0")
assertEquals(outer, PurchaseScrollLocator.locate(outer, listOf(nested, outer)))
}
@Test fun changedPathRequiresUniqueFullBoundsMatch() {
val moved = outer.copy(path = "0/2")
assertEquals(moved, PurchaseScrollLocator.locate(outer, listOf(inner, moved)))
assertNull(PurchaseScrollLocator.locate(outer, listOf(moved, moved.copy(path = "0/3"))))
}
@Test fun reusedPathWithDifferentGeometryIsRejected() {
assertNull(PurchaseScrollLocator.locate(outer, listOf(inner.copy(path = outer.path))))
}
@Test fun classAndAllEdgesAreValidated() {
assertNull(PurchaseScrollLocator.locate(outer, listOf(outer.copy(className = "ScrollView"))))
assertNull(PurchaseScrollLocator.locate(outer, listOf(
outer.copy(bounds = NodeBounds(0, 1138, 1080, 1979)),
)))
}
}
@@ -0,0 +1,47 @@
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)))
}
}
@@ -0,0 +1,81 @@
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 `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())
}
}
+9 -4
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
wiki_revision: 6207da4ffac6b5821eda7869b9ea335b7585826d
synchronized_at: 2026-08-31T16:06:21Z
wiki_revision: 3b78360779ae520f1ff9e51be3118a04cd549f51
synchronized_at: 2026-09-07T09:27:40Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
@@ -21,16 +21,20 @@ synchronized_at: 2026-08-31T16:06:21Z
| 后续设计范围 | 从 SYB 创建采购任务、创建待付款订单、物流采集与自动回填 |
| 预计规模 | 20 台 Android;每天约 100 个采集任务、200 个采购任务 |
## 项目治理模式
GoAuto 默认采用轻量治理:文案、注释、格式、局部样式或布局、预期行为明确的小 Bug,以及不改变接口、数据结构、权限和安全边界的单模块低风险调整可以直接实施,无需为了留痕补建工单。完整独立需求、新页面、跨模块功能,以及涉及 API、数据结构、权限、安全、迁移或范围不明确的变化必须建立单元工单。采购、创建订单、真实个人或生产数据、权限、安全、并发、迁移、删除、发布和不可逆操作始终升级为高风险,必须具备对象和范围明确的人工授权;已有有效授权时不机械重复确认,范围或环境发生实质变化时重新确认。永久禁止付款以及设备、隐私、AI 和真机安全红线不可裁剪。
## 建设基线
| 基线 | 来源与版本 | 许可证 / 使用方式 | GoAuto 适配 |
|---|---|---|---|
| DevHarness | `D:\OPC\dev_harness`,目标提交 `4bbacf4d7fb265984396bb5589c544105043fa0b` | 开发流程与文档模板 | 2026-08-27 升级(#114):在 #76 已采用的单人工单事实源基础上,增量加入部署模板、Gitea MCP 与最小工单读取、线上原型默认审核与按需 HTML 导出、PowerShell UTF-8/ExecutionPolicy 边界;继续保留 GoAuto 专用安全与设计门禁 |
| DevHarness | `D:\OPC\dev_harness`,目标提交 `ecab899` | 开发流程与文档模板 | 2026-09-05 升级(#221):在既有单人工单事实源基础上,选择性加入轻量治理、明确授权边界、中文默认沟通、Windows PowerShell 安全规则,以及按 revision 增量同步与显式深度检查;继续保留 GoAuto 专用高风险与安全门禁 |
| 服务端 | `go-admin` v2.3.0 | 上游开源管理端基线;升级时复核许可证和安全公告 | 保留认证、菜单、配置和管理端基础能力,新增 GoAuto 业务模块 |
| 管理端 | `go-admin-ui` v3.0.0,`web/package.json` 标注 MIT | Vue 管理界面基线 | 保留应用外壳与通用组件,新增 GoAuto 页面 |
| Android | 原生 Kotlin Agent | 自研业务客户端 | 通过管理员明确配置的 HTTP 或 HTTPS Origin 直连服务端,不保留 Windows 桌面 Client/ADB 作为生产拓扑 |
升级必须比较当前记录的目标提交与新的明确提交,不能笼统复制“最新版”。本次上一基线为 `bfdf648962d11a8024f62768380d8571e1f45f68`,目标为 `4bbacf4d7fb265984396bb5589c544105043fa0b`,区间共 13 个提交;更早基线 `b1f500128d6eb100985792d4a715db8b6b5ae203` 的适配见 #47。模板内容一律按 GoAuto 事实改写:不复制 DevHarness 的项目事实、任务记录、占位部署参数或历史归档;`harness.py` 继续校验 GoAuto 实际核心页面与产品 README,GoAuto 更严格的付款、订单、设备、数据和真机门禁继续优先。
升级必须比较当前记录的目标提交与新的明确提交,不能笼统复制“最新版”。本次上一基线为 `4bbacf4d7fb265984396bb5589c544105043fa0b`,目标为 `ecab899`,选择性适配其后的 7 个提交;更早基线适配见 #47 与 #114。模板内容一律按 GoAuto 事实改写:不复制 DevHarness 的项目事实、任务记录、占位部署参数或历史归档;`harness.py` 继续校验 GoAuto 实际核心页面与产品 README,GoAuto 更严格的付款、订单、设备、数据和真机门禁继续优先。
## 交付单元
@@ -57,6 +61,7 @@ synchronized_at: 2026-08-31T16:06:21Z
## 环境与凭据
- 服务端正式环境默认必须使用 HTTPS。仅当管理员接受 Device Token、任务内容和执行结果明文传输风险,并显式设置 `GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true` 时,Agent `/api/agent/v1/**` 可通过 HTTP;管理端和第三方服务不因此放宽。
- #237 客户端密钥例外(用户 2026-09-07 明确接受风险):提交 `71f7751` 起,管理员密钥管理及 `/api/client/v1` 默认兼容 HTTP/HTTPS,无开关;HTTP 明文传输密钥及业务数据,建议优先 HTTPS。实际迁移部署仍须单独授权;此例外不改变其他第三方服务的安全边界。
- 每台设备使用独立 Device Token;Token 只存安全配置,不进入仓库。
- PDD 账号密码、Cookie、验证码和用户个人数据不得进入日志。
- 根目录 `gitea.env` 是本机工单访问配置,已被 Git 忽略。
+22 -3
View File
@@ -2,12 +2,18 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Development-Workflow
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.-
wiki_revision: 3167457d79824afe532dd5560990af8a485a57d1
synchronized_at: 2026-08-27T09:37:51Z
wiki_revision: 62ddbe4469740c02ce4a6ca2fd1966a89a79322f
synchronized_at: 2026-09-05T07:16:44Z
<!-- gitea-wiki-mirror:end -->
# 开发工作流
## 语言与术语
- 用户可以使用中文、英文或合理的中英混合语言交流;默认使用中文分析、回复、编写工单和维护内部项目文档。
- 代码标识符、命令、参数、路径、文件名、API 名称、协议名、日志和错误原文保持原样;必要时补充简短中文解释。
- 用户明确要求某次回复或交付物使用其他语言时,按该次要求执行,不改写接口契约或影响搜索和执行的原文。
## 事实来源
| 信息 | 唯一事实来源 |
@@ -18,7 +24,7 @@ synchronized_at: 2026-08-27T09:37:51Z
| 源码、迁移、测试、版本绑定分析、本地原型和核心 Wiki 镜像 | Git |
| 可编辑交互设计 | QuantUX;App ID、版本、链接和确认状态记录在工单 |
核心页面通过 `wiki-docs.json` 显式映射,固定执行 Wiki → `docs/` 单向同步。既有 Wiki 任务归档和 `docs/task/` 只作历史兼容;标准任务不创建或导出,只有用户明确要求专项快照时才使用 `archive` / `export`。
核心页面通过 `wiki-docs.json` 显式映射,固定执行 Wiki → `docs/` 单向同步。日常 `sync` 与 `sync --check` 先比较页面 revision,revision 未变化时不重复下载正文;疑似镜像损坏或需要完整核对时显式使用 `sync --deep-check`。既有 Wiki 任务归档和 `docs/task/` 只作历史兼容;标准任务不创建或导出,只有用户明确要求专项快照时才使用 `archive` / `export`。
## 权威源与事实边界
@@ -53,6 +59,19 @@ synchronized_at: 2026-08-27T09:37:51Z
- 每个核心页面写入后必须在线回读并取得 revision。页面缺失、回读失败或没有 revision 时停止初始化。
- 产品编码前运行 `python dev_scripts/harness.py sync --verify`;全部成功才表示初始化完成。
## 项目治理模式与不可裁剪底线
GoAuto 默认采用轻量治理。文案、注释、格式、局部样式或布局、预期行为明确的小 Bug,以及不改变接口、数据结构、权限和安全边界的单模块低风险调整可以直接实施,无需为了留痕补建工单。完整独立需求、新页面、跨模块功能,以及 API、数据结构、权限、安全、迁移或范围不明确的变化必须建单。
采购、创建订单、真实个人或生产数据、权限、安全、并发、数据库迁移、删除数据、发布和其他不可逆操作始终升级为高风险。无论任务采用何种最小门禁,凭据保护、永久禁止付款、个人与生产数据最小化、设备互斥、Agent 禁止 OCR/VLM、控件树与整屏截图禁存、工作区保护、真实测试和人工验收均不可裁剪。
### 明确授权后的执行
- 当前聊天中用户给出的明确指令,或 Gitea 工单中能够归属于有权人工的明确授权,可以作为执行依据,不要求把同一授权重复复制到工单后再次确认。
- 授权必须能识别操作、对象和范围;Agent 自动生成的工单、草稿、摘要或对用户意图的转述不能单独构成人工授权。
- 获得有效授权后,只核对准确目标、授权范围和当前状态等最小必要前提,不得仅因操作不可逆而重复询问或拒绝。
- 授权不自动覆盖相邻对象或后续任务;环境、对象、范围或影响发生实质变化时重新确认。平台自身强制的审批、安全策略或权限限制继续有效。
## 工单与设计证据双门禁
正式实施前先判断是否需要工单,再判断需要什么设计证据。工单不能替代原型确认,原型也不能替代技术方案、安全检查和单元工单。
+45 -11
View File
@@ -2,16 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: de7e400503d6e7b9c335657114893de345bfead5
synchronized_at: 2026-08-31T03:33:36Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: dc5ccd2104e8ded715c0c0bb23d3827dc9a7fd7a
synchronized_at: 2026-08-29T01:39:35Z
wiki_revision: 20a1ca65dccde9ca1ecd93419cc1b164a5717fdf
synchronized_at: 2026-09-07T13:34:45Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -56,7 +48,7 @@ Android Portal/Agent
- Android 本地互斥与服务端原子领取共同保证单设备串行。
- 正式采购在 Android 本地 SQLite 事务中先保存不可逆状态、稳定服务端请求 ID 和脱敏最终确认快照,再通知服务端并只允许一次创建订单点击;重启后只重放服务端标记、只读核单或上报,不再次点击。
- 原始控件树和截图不持久化;Android Agent 端第一期不使用 OCR/VLM。服务端 SYB 登录验证码识别是唯一例外,见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48)。
- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色;尺码自动匹配只预览格式统一后的唯一确定结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。
- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色。Admin 商品详情的“一键匹配颜色和尺码”在服务端统一计算两个维度:保留当前仍有效的已确认映射,将唯一确定匹配及达到阈值、具备理由且候选仍有效的 AI 匹配,在重新校验规格上下文后于同一事务直接写为 `confirmed`,无需人工确认;低置信度或无结果保持未匹配,Provider 异常或上下文变化时不写入任何本次结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。
## 最小业务数据
@@ -318,3 +310,45 @@ PddProductDetailCollector
- `GET /api/admin/v1/pdd-products/{productId}/related-syb-products` 返回统一扁平分页,参数为 `page`、`pageSize`(默认 20、最大 100)、`scope=actionable|all`(默认 actionable)和可选 `shopeeProductId`。管理员和采购员可读,其他角色由 Casbin 拒绝。
- `purchase.Service.ProcessStages` 是 SYB 页和 PDD 关联订单共用的只读阶段入口;批量加载、不调用 AI。当前采购规则缺失或无效时仍返回基础关联、采集和已有任务事实,依赖规则才能创建的行明确标记不可采购。
- Web 的 PDD 商品详情把“创建备货采购”保留在顶部,把“关联订单继续采购”放在关联商品区块;订单行使用统一分页,并复用既有 `batch-preview` / `batch` 创建路径。
## 蝦皮规格自动匹配批处理(#195)
- 服务端在 `server/app/goauto/shopeeproduct/auto_match_batch.go` 复用单商品“一键匹配颜色和尺码”原子服务,定时任务与 Admin 手动执行共用同一批处理入口。
- `shopee_spec_auto_match_run` 保存触发来源、稳定 `requestId`、运行摘要和可空唯一 `active_slot`;活动槽与租约保证多实例、定时和手动同时触发时全局最多一个运行批次。
- `shopee_spec_auto_match_work_item` 按蝦皮商品唯一保存输入指纹、尝试次数、下次尝试时间和逐商品租约。已完成或低置信度/无结果的相同输入不重复调用 AI;规格、关联或 AI 设置更新时间变化后才允许重新处理。
- 迁移 `1788290000000_shopee_spec_auto_match.go` 幂等创建两张表并写入调用目标 `GoAutoShopeeSpecAutoMatch`。系统任务默认关闭,默认 Cron 为每小时第 15 分钟、每批最多 20 个商品。
- Admin 蝦皮商品列表通过异步手动接口启动同一批次并轮询最近运行摘要;该入口仅管理员可用,不创建采购任务、订单,不调用 Android Agent,也不执行付款。
## SYB 异常规格 AI 定时解析(#198)
- `server/app/goauto/sybimport/ai_parse_batch.go` 以 SYB 明细为单位执行“确定性重解析 → 封闭候选 AI 解析”,与 #195 的“蝦皮规格 → PDD 规格”任务保持独立。
- `syb_spec_ai_parse_run` 保存全局批次、活动槽、租约与结构化计数;`syb_spec_ai_parse_work_item` 按 `syb_product_id` 唯一保存输入指纹、尝试次数、冷却和逐行租约。
- `syb_product` 的 `ai_confirmed`、置信度、限长理由、确认时间和隐藏输入指纹记录 AI 确认事实;`parse_status` 仍只记录确定性解析器结果,`manually_confirmed` 仍只代表人工决定。
- Provider 只接收单条 `productSpec` 和关联蝦皮商品的颜色/尺码候选;返回值必须逐字属于对应候选。采购可信门禁接受仍有效的 AI 确认,但候选消失后立即 fail-closed。
## 定时任务持久化执行历史(#199)
- `server/app/jobs/execution_log.go` 为 Exec 与 HTTP 两类调度入口统一记录一次执行生命周期;`server/app/jobs/models/sys_job_execution_log.go` 对应表 `sys_job_execution_log`,保存执行标识、任务/调用目标快照、定时触发类型、开始/结束时间、耗时、`running` / `succeeded` / `failed` / `interrupted` 状态及脱敏错误码和摘要。
- 服务启动时按当前“每个数据库一个调度器实例”的拓扑,把上次进程遗留的 `running` 记录安全收敛为 `interrupted`;任务删除后历史仍保留并可只读查询。
- `GET /api/v1/sysjob/:id/execution-logs` 由 `server/app/jobs/service/execution_log.go`、`apis/execution_log.go` 和 `router/sys_job.go` 提供任务级分页、状态与开始时间过滤;沿用隐藏菜单 `JobLog` 的角色菜单绑定和精确 GET 权限。Web 入口为 `web/src/views/schedule/index.vue` 的单选“日志”按钮,详情页为 `web/src/views/schedule/log.vue`。
- 执行历史明确不保存任务参数、AI Provider 地址或密钥、第三方原始响应和业务原始载荷;当前不提供删除、保留期限自动化、WebSocket 实时流或立即执行动作。
- 追加迁移为 `server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log.go`:创建执行历史表、登记只读 API、关联 `JobLog` 菜单,并只给迁移前已绑定该菜单的角色补充精确 Casbin 权限。
## 客户端密钥访问架构(#237)
代码基线 `71f7751`(含用户确认的默认 HTTP/HTTPS 兼容),2026-09-07 完成 Server/Web 代码与隔离测试;本机迁移、管理员菜单写入与 Server/Web 启动已于 2026-09-07 授权完成,真实 HTTP 管理列表和模块目录加载通过;线上仍未部署。
- `server/app/goauto/clientkey` 管理独立密钥、授权及审计;`clientapi/routes.go` 的显式 Inventory 将 `/api/client/v1` 映射到既有业务处理器,绝不转发任意 Admin 路由。
- `clientapi/gateway.go` 默认接受 HTTP 与 HTTPS,无协议开关或转发协议头门禁,按 Bearer 密钥、模块及能力顺序校验,每次请求读取数据库,无授权缓存。`common/clientprincipal` 传递独立客户端身份,不生成或伪装管理员 JWT。
- `client_api_key` 保存名称、随机 256 位密钥的 SHA-256 摘要、前缀、授权 JSON、启用状态、版本、创建/修改人和最后使用时间;完整密钥仅创建响应一次返回。`client_api_key_audit` 保存管理变更和客户端请求元数据,不保存请求正文、查询参数、响应正文或凭据。
- 编辑与停用采用启用状态和 version 条件更新,并与变更审计同事务提交;冲突返回 409。业务执行前先持久化请求审计意图,失败则不执行业务。完成后更新状态;更新失败或进程中断可能留下 status=0,表示结果待核对,不能据此自动重放。
- 部分既有业务操作人字段使用该密钥最近授权管理员的 ID 兼容现有外键;实际调用方以独立审计的 key_id 为准,不能把业务字段当成人工操作证据。
- Admin 页面 `web/src/views/goauto/client-keys/index.vue` 复用创建/编辑授权弹窗;菜单位于“采采管理”,仅管理员可见。新追加迁移 `1788798000000_client_api_key.go` 创建两表及管理员菜单,不改 Android。
## 采购规格面板预滑动兼容(#238)
代码基线 `58a6c1c`,Android 0.9.60 / versionCode 73(构建完成不等同于已安装/发布)。`PurchaseRehearsalExecutor.applyPostAction` 对 `openSpecPanel.swipeAfter` 只兼容解析、不执行机械预滑动,`waitAfterMs` 保留;首趟继续原 `probeSpecs` 遍历,第二趟继续原 `selectSpec` 精确查找与容器内有界滚动。其他动作的后置滑动仍沿用既有执行语义,失败不会被统一忽略。
`GoAutoAccessibilityService.swipePurchase` 的失败分类由 `PurchaseSwipeFailureReason` 枚举提供;共享 `swipeNode` 仅增加可选分类回调,不改变手势目标、轨迹、1500ms 回调等待或其他调用者行为。`GoAutoPurchasePanel` 日志经 `AgentForegroundService` 关联 task、attempt、device 与规则快照哈希,新增预滑动跳过/必需滑动失败标量;不记录节点文字、坐标、原始控件树、截图或凭据。
Server/Web、数据库和任务快照不变;旧 APK 仍有预滑动行为,必须更新 Agent 才生效。相关验证在 `PurchaseRehearsalExecutorTest`,Android 全量测试与 APK 构建入口不变。
+111 -27
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: c7f56f233263b3c83df7e31cf75c89c48ff24111
synchronized_at: 2026-09-04T10:22:02Z
wiki_revision: a445dfe3b76336f5c7387a2da9760c5d6e383ff6
synchronized_at: 2026-09-09T02:47:12Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -58,6 +58,13 @@ synchronized_at: 2026-09-04T10:22:02Z
- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、规格待匹配、可创建采购、已创建任务、采购成功、待人工核对。
- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 规格待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。
- “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。
- 一个 PDD 订单号只能属于一个采购任务,该唯一性在采购任务保存路径上全局强制(#241)。人工处理结果未知、取消及 lifecycle 保存路径撞号时返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`,提示订单号已属于哪个任务,由采购员人工核对,不静默覆盖原值。
- 不可逆边界之后的 `order_created` 结果回传是上述规则的例外:此时 PDD 真单已创建,发现订单号已属于其他任务时不回滚、不判失败,而是把任务降级为 `order_result_unknown`,冲突订单号以「读到订单号 X,但该号已属于任务 CG-yy」保存在任务与 attempt 的 `error_message`,`pdd_order_no` 留空以维持唯一性,保留下单时间与不可逆时间,进入既有人工处理结果未知通道。首要目标是保住「真单已存在」这一事实,不制造无记录的真实订单。
- Agent 可按收货地址后缀 `_cg<任务号>` 批量回填订单号与下单时间(#241)。
- Agent 侧回填为**人工触发的只读扫描**(#242):入口在采购记录页搜索按钮右侧,输入天数(默认 2)后确认启动。天数口径为滚动 N×24 小时,基准是页面读到的下单时间。采集、采购、回填三者互斥,复用既有任务互斥锁并加原子防重入,忙碌时拒绝启动而不排队、不抢占。
- 回填扫描不假设订单列表有序:只有连续观察到至少 5 单且下单时间严格递减、期间无缺失时间时,才允许「超过指定时间即停止」;一旦出现时间回升或缺失,改为扫至内部上限并明确标记「未完整扫描」,不得把不完整结果显示为已扫完。内部上限用于限制设备占用,不作为用户可配置的门禁。
- 回填扫描全程只读:确认收货、申请退款、催发货、去支付、立即支付、提交订单、付款、退款、取消订单、再次购买、删除订单永久排除为点击目标;点击目标必须自身可点、子树不含上述词,且不与任何含上述词的可见控件几何重叠;不得按固定坐标盲点。遇登录、验证码或风控立即停止并报告。
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号与可选下单时间。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
- 已失败、已取消或演练完成的旧采购任务不单独占用主阶段;当前数据仍满足条件时恢复显示“可创建采购”,旧任务继续在采购管理留痕和按既有规则处理。
- 未选择处理阶段时,商品列表仍先返回,当前页阶段和采购准备继续异步批量读取且不调用 AI Provider;选择阶段筛选时,服务端必须先对完整查询结果派生并筛选阶段,再计算总数和分页,不能只过滤当前页。
@@ -67,13 +74,13 @@ synchronized_at: 2026-09-04T10:22:02Z
- 版本迁移会对历史存活店铺回填 `normalized_name = Normalize(display_name)`;回填只改匹配键且可重复执行。若两个存活店铺回填后会得到同一键,迁移必须整体失败并保留原数据,管理员先人工消除歧义后再执行,不能静默合并、删除或改变店铺启用状态。
- 店铺可以从 SYB 真实货运单列表发现,也允许管理员手工补充。只有管理员可以新增、改名、启停和软删除,采购员等其他角色只读。
- 没有任何启用店铺时,导入必须在读取凭据、建立会话、验证码 OCR 和任意 SYB 网络请求之前失败,并给出“请先启用店铺”的可读提示。
- 同步必须先拉取并校验当天原始全量列表的总数、分页和唯一 ID,再按本次同步开始时固定的启用店铺快照过滤;过滤不能降低完整性校验的请求范围或容量上限。
- 同步先预检全范围总数及上限,每页校验原始列表条数、合法 ID 和重复,再按冻结店铺快照获取明细并页事务保存;整日结束核对总数。过滤不能降低完整性校验范围,已保存不能冒充整日完整(#239,c6a962d,已于 2026-09-08 随 d403f3b 部署线上)。
- 只有列表与明细响应的店铺名都非空且命中启用快照时才允许写入。明细店铺名为空、变化为未启用店铺或无法匹配时跳过,并计入跳过数量。
- 停用或软删除店铺只影响后续导入,不删除历史 SYB 商品、虾皮商品或任务数据。已有错误导入数据的清理必须先给出精确 SQL 和影响行数,再由用户单独确认。
## SYB 后台导入记录
- 导入由管理员创建,创建成功后立即转入后台执行;页面关闭不取消任务。采购员等其他已登录角色可以查看同步记录,不能开始导入。
- 导入允许管理员和采购员(purchaser)创建,创建成功后立即转入后台执行;页面关闭不取消任务。其他已登录角色只能查看同步记录,不能开始导入(#236)。
- 系统同一时刻只运行一个 SYB 导入任务。`syb_sync_run` 的唯一执行槽负责跨进程互斥,内存锁减少同一进程内的竞争;服务重启后遗留的执行中记录标记为“已中断”。
- 导入失败或中断时保留已写入商品,记录明确的失败原因和已处理进度;重新导入相同范围按「订单号 + 明细 ID」覆盖,不产生重复商品。
- 创建同步记录时冻结本次启用店铺的规范化匹配集合、展示名称快照及 SHA-256 哈希;后台执行必须只使用这一份快照,不得在开始后重新读取 `syb_shop`。同步详情保存并返回快照可用标记、快照店铺名称、哈希及各店铺“已导入/已跳过”数量。#212 之前的历史记录只有哈希和统计,明确标记为无完整快照。上述记录不保存账号、密码、Cookie、Token、验证码图片或 SYB 原始响应。
@@ -140,11 +147,11 @@ synchronized_at: 2026-09-04T10:22:02Z
- 演练规则在服务端契约层拒绝改地址、创建订单和核单动作;正式动作还必须通过版本化能力协商。规则只包含类型化动作,禁止任意脚本。
- 价格保护保存参考单价、最低单价、最高单价和币种,执行时以 PDD App 实际单价判断;价格越界明确失败,不考虑优惠券。
- Admin 从 SYB 当前页选择商品,可在“创建采购”和“重新解析”两种批量用途间切换;采购模式只允许选择预检合格行,表头全选不跨页。批量预检和正式创建均最多 100 条并逐条重新校验,每条 SYB 明细只创建一个独立任务;部分失败不回滚其他成功项。
- SYB 商品列表在列表数据返回后立即展示,采购准备状态独立异步加载;只读批量预检只执行批量数据库读取、已确认映射和本地确定性识别,禁止调用 AI Provider。未保存的确定性结果仍显示“规格待匹配”,不能作为“可创建采购”的依据。SYB 批量创建采购按最新数据完整复核,只有目标颜色和尺码都已保存为确认映射,且共同命中最近一次成功或部分成功采集中的完整可售 SKU 组合时才允许创建;否则必须先执行批量 AI 匹配或人工确认,不能把未匹配明细带入采购任务内的人工匹配步骤。
- SYB 商品列表在列表数据返回后立即展示,采购准备状态独立异步加载;只读批量预检只执行批量数据库读取和本地确定性识别,禁止调用 AI Provider。自 #215 起,未保存或已失效的长期规格映射不再阻断创建采购;满足商品关联、PDD 状态、价格、规则、设备和订单安全门禁后即可创建任务,每个新 SYB 采购任务都在首个 attempt 以当次 PDD 页面候选重新决策。
- SYB 商品页的 PDD 采集资格独立于采购处理阶段:只要已关联的 PDD 商品未停用、没有 `pending` / `running` 采集任务且存在可用采集规则,即可创建采集任务;因此已完成采集并进入“可创建采购”的商品也可以重新采集。相同 PDD 商品在批量创建前按商品去重,服务端创建时仍按最新状态复核。
- 批量创建读取管理员选定的当前采购规则并重新执行契约校验,每条任务保存不可变快照;当前规则缺失或无效时明确阻断,不回退到代码常量。设备默认人工指定,也可以留空由符合能力的空闲设备领取。
- 批量创建的价格保护来自 PDD 商品档案而不是 SYB/Shopee 的 TWD 售价:当前采购规则可用 `priceGuard.minRatio`(0.1~1.0)和 `maxRatio`(1.0~3.0)配置比例,缺省仍为 0.2 / 1.5;最低价向下取整、最高价向上取整到人民币分。已确认颜色映射时按该颜色计算,待规格探测时以全部可用颜色的最低/最高价计算,参考价取最高价;没有可用颜色价格时不能创建。
- 地址后缀为 `_cg{purchase_task.id}`。选好规格和数量后,Agent 仍停留在当前规格/下单面板;若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;脱敏手机号文字本身不可点击时,仍以该唯一文字节点的中心坐标执行一次精确手势点击,不沿用可能覆盖整个下单面板的可点击祖先。“修改”“保存”和“提交订单”等文字节点即使依赖可点击父节点,也必须保留原始文字节点作为每次重新定位的锚点,父节点只用于验证存在可点击路径。近乎完全重叠的无障碍重复节点按一个目标处理,仍有多个独立目标或页面切换超时则明确失败。按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。
- 地址后缀为 `_cg{purchase_task.id}`。精确规格、数量、价格和摘要核验通过后,若仍处于已识别的规格面板,Agent 只允许点击唯一、可见、启用、可点击且文案精确命中“确定/确认”别名的规格确认控件;不得点击提交订单、确认购买、付款、支付、地址修改或地址保存控件,也不使用中心手势兜底。点击后必须重新读取并确认已出现订单确认页或地址入口强证据,否则明确失败。进入订单确认页后,若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;脱敏手机号文字本身不可点击时,仍以该唯一文字节点的中心坐标执行一次精确手势点击,不沿用可能覆盖整个下单面板的可点击祖先。“修改”“保存”和“提交订单”等文字节点即使依赖可点击父节点,也必须保留原始文字节点作为每次重新定位的锚点,父节点只用于验证存在可点击路径。近乎完全重叠的无障碍重复节点按一个目标处理,仍有多个独立目标或页面切换超时则明确失败。按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。
- 地址编辑页可以同时存在收货人、手机号和详细地址等多个输入框;Agent 只选择与“详细地址”标签纵向重叠且位于其右侧的唯一输入框,不能用页面输入框总数或顺序猜测。
- 点击创建订单前,Agent 必须先在本地事务保存 `order_submit_started`、不可逆时间、稳定请求 ID 和不含地址全文的最终确认快照,再用同一请求 ID通知服务端;两侧成功后才允许精确点击唯一创建订单按钮一次。
- 进入不可逆边界后,进程重启、断网、点击结果不明或无法取得唯一订单号/下单时间时只允许只读核单并进入 `order_result_unknown`,禁止再次点击;任务与订单正式关联仍以完整 PDD 订单号为准。
@@ -152,15 +159,15 @@ synchronized_at: 2026-09-04T10:22:02Z
- Agent 本地 SQLite/Outbox 负责断网和重启恢复,服务端以 `task_id + task_attempt_id` 幂等接收并保存最终事实。
- 人工支付复核只记录 `paid` / `unpaid`;系统不执行或识别支付。快递单号与回填状态属于采购任务,后续物流工单实现。
- 采购任务领取时同时占用设备租约和可选 PDD 账号租约;租约过期后才可释放并重新领取。设备还存在采集任务时不能领取采购任务。
- 采购规格由服务端按顺序决策:先使用已确认的人工映射;否则仅在同一规格角色的可选 PDD 原始标签中做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算);仍无唯一结果才调用已启用的服务端 AI。AI 必须返回候选集中的原始标签,候选不完整、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。
- 自 #215 起,每个新 SYB 采购任务无论是否存在已确认长期映射,都先由 Agent 在当次 PDD 规格面板内有界遍历颜色和尺码并回传原始候选。服务端只以任务冻结的 SYB 目标规格与当次候选先做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算),仍无唯一结果才调用已启用的服务端 AI。AI 必须逐字返回当次候选集中的原始标签;候选不完整、集合外返回、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。
- Agent 选择服务端下发的精确颜色或尺码时,只能在已确认打开的规格面板内有限纵向滑动、每次重新读取可选节点并按完整原始文字点击;连续没有新证据或达到上限即停止,不得点击相近规格。第一趟探测并固化规格后,第二趟仍无法精确选择而再次提交探测时,服务端必须明确失败并释放活动槽,保留第一次决策证据,禁止清空决策、循环派发或进入地址与创建订单动作。
- 采购 Agent 在确认进入 PDD 商品页后、打开规格前,复用采集侧的假售罄识别;规格面板已打开且当前解析到的规格值全部不可选时也按售罄处理。两种情况都只允许关闭规格面板后对商品页执行一次有界恢复(默认下拉 2 次、间隔 1000 毫秒、等待 2000 毫秒),恢复动作失败或恢复后仍售罄时返回 `PDD_GOODS_SOLD_OUT`,恢复后商品页证据丢失时按页面规则不匹配失败;不得继续选择规格、修改地址或创建订单。该失败码继续进入既有替代商品资格判定。
- 规格映射不完整、已保存的确认映射对当前 PDD 档案失效、确定性匹配失败、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;自 #190 起前两种情况不再拒绝创建,而是把 `spec_source` 降级为 `unresolved` 并交由规格探测解析,规则不具备该能力时仍然拒绝创建;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。Android 不自行匹配或猜测;其只接收服务端已经固化的精确原始规格标签。
- 所有新 SYB 采购任务的规则都必须具有 `purchase.spec-probe.v1`;创建时任务级 `mappedColor` / `mappedSize` 为空且 `spec_source=unresolved`,长期映射和 PDD 档案候选都不能跳过首趟探测。第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟;探测阶段从服务端拒绝进入 `order_submit_started`。Android 不自行匹配或猜测,只在第二趟接收并精确选择服务端固化的当次 PDD 原始标签。
- Agent 提交的相同 attempt 最终结果只能写入一次;相同请求重放返回原事实,不同内容拒绝覆盖。`order_result_unknown` 不参与自动派发,只能人工解除。
- 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。已标记为已支付的订单不能授权或创建重新采购任务。
- 人工回填候选只允许从已支付订单选择;同一 SYB 明细后来选择的订单覆盖旧候选,但不删除旧订单事实。
- Admin 采购管理只查看和处理已有任务,不提供创建入口或支付按钮;单条和批量采购任务都从 SYB 商品列表发起。订单结果未知时必须先人工核对并解除;处于该状态时页面不提供重新采购授权。
- 采购失败任务可在采购管理当前页批量勾选重试,最多 100 条。重试不修改旧任务,而是用当前 SYB/PDD 档案、当前规格映射、当前价格保护和当前采购规则创建新的 `pending` 任务,并生成新任务编号与地址后缀;来源蝦皮订单号沿用失败任务的不可变快照。
- 采购失败任务可在采购管理当前页批量勾选重试,最多 100 条。重试不修改旧任务,而是用当前 SYB/PDD 档案、目标规格、当前价格保护和当前采购规则创建新的 `pending` 任务,并生成新任务编号与地址后缀;来源蝦皮订单号沿用失败任务的不可变快照。新任务不复用旧任务或长期档案的执行规格,仍必须先完成当次真机规格探测。
- 只有正式采购、未进入不可逆边界、没有订单号或下单时间、且仍是同一 SYB 最新记录的 `failed` 任务可重试。原设备离线、停用、忙碌或能力不足时该项失败且不自动换机;未指定设备时仍由空闲设备领取。
- 批量重试逐项处理并允许部分成功;同一请求幂等重放不会重复创建。失败任务不再使用一次性重新采购授权,该授权只保留给已经创建过订单且满足条件的任务。
@@ -168,7 +175,7 @@ synchronized_at: 2026-09-04T10:22:02Z
- 管理端固定支持 `admin`(管理员)和 `purchaser`(采购员)两类业务角色;用户必须绑定一个存在且启用的角色,`role_id=0` 或停用角色不能创建或保存。
- 采购员可读取设备状态,维护 PDD/虾皮商品和规格映射,查看与修正 SYB 商品,读取 SYB 店铺及同步记录,读取采集规则,创建/重置/删除采集任务,并创建、查看、重试及人工处理采购任务。
- 仅管理员可管理用户、角色、菜单、接口、部门和岗位;停用设备或吊销 Device Token;新增、改名、启停、删除或发现 SYB 店铺;手动启动 SYB 同步;新增、编辑或删除采集规则;保存或测试 AI Provider 配置。
- 仅管理员可管理用户、角色、菜单、接口、部门和岗位;停用设备或吊销 Device Token;新增、改名、启停、删除或发现 SYB 店铺;新增、编辑或删除采集规则;保存或测试 AI Provider 配置。
- AI 规格匹配菜单对采购员硬排除:采购员不显示该菜单,角色配置也不能为采购员选中该模块;既有 API 权限不变,仍只允许读取是否启用,不得读取 Provider 地址、模型、API Key,也不得保存或测试。
- GoAuto 菜单由代码维护的模块定义幂等写入系统菜单和菜单/API 关联;迁移只为采购员追加首次引入且默认开放的模块,不会把采购员人工取消勾选的既有模块重新加回。
- 采购员 Casbin API 白名单由代码权限矩阵全量重建,不从角色菜单勾选反推;减少权限时旧策略必须删除。菜单勾选只控制可见模块,不能扩大采购员 API 权限。
@@ -230,7 +237,7 @@ synchronized_at: 2026-09-04T10:22:02Z
- 定时任务失败时明确记录失败原因,不自动重试。服务重启后由既有启动恢复逻辑处理遗留的运行中记录。
- 单次同步内部的 SYB 只读请求(货运单总数、列表和明细)遇到暂时网络故障、5xx 或异常响应时最多执行 3 次,分别退避 1 秒、2 秒,单次 HTTP 总超时 60 秒;这不等于失败任务自动重试。明确未登录、业务失败、数据完整性错误以及任何写操作均不自动重试。
- SYB 商品页不再提供“导入”和“同步记录”快捷按钮,也不查询、展示或轮询同步状态;后台同步成功、失败或中断均不在商品页弹出消息,商品数据由用户主动查询或刷新获取。
- 独立“SYB 同步记录”页面是同步结果的唯一 Web 展示位置,保留运行状态、进度、数量、失败原因和店铺统计;go-admin 定时任务中的 `GoAutoSYBHourlySync` 提供“执行记录”入口。管理员可在同步记录页人工发起覆盖昨天和今天的同步,采购员只读;人工与定时同步共用导入服务、执行记录和互斥,不排队、不并发,也不立即重试。
- 独立“SYB 同步记录”页面是同步结果的唯一 Web 展示位置,保留运行状态、进度、数量、失败原因和店铺统计;go-admin 定时任务中的 `GoAutoSYBHourlySync` 提供“执行记录”入口。管理员和采购员可在同步记录页人工发起覆盖昨天和今天的同步,其他角色只读(#236);人工与定时同步共用导入服务、执行记录和互斥,不排队、不并发,也不立即重试。
## cmautobuy 商品导入
@@ -256,16 +263,14 @@ synchronized_at: 2026-09-04T10:22:02Z
## Agent 受控重试采购
- 当前设备只可就地重跑自身最近 30 天内、服务端标记 `retryable=true` 的正式采购失败任务;列表和详情都只能发起单任务重试,不支持多选、批量或自动重试。
- 普通“重试采购”复用原 `purchase_task.id`,不新建任务,不改变商品、虾皮/SYB 身份、目标规格、已映射规格、价格保护、地址后缀与其他业务快照。重试只把任务恢复为 `pending`,清理错误、租约和运行守卫。
- 重试时刷新服务端当前采购规则及其类型、schema 和能力要求。#127 尚未实施前,当前规则仍为服务端内置规则;#127 完成后才切换为数据库单例设置,不能在本工单提前引入第二事实源。
- 新规则下发前必须重新校验原设备在线、空闲且具备全部能力。规则不可用或能力不匹配时拒绝重试,原任务保持失败状态。
- 已出现 `order_submit_started` 证据,或存在不可逆时间、订单提交请求、PDD 订单号、下单时间的任务一律拒绝就地重试,并提示走既有“授权重新采购”流程,防止重复下单。
- 每次成功受理就地重试都会为同一任务预建一个新的 `pending` attempt,保存本次规则哈希;正常 Start 复用并转为 `running`。因此尝试次数、每次规则哈希和失败原因均可追溯,且不限制人工重试次数。
- `requestId` 按“任务 + 请求”幂等;相同请求重放不重复修改任务或增加 attempt。
- Agent 详情显示已尝试次数与上次失败原因。确认界面必须说明同一任务使用最新规则重跑、可能创建待付款订单且系统不会支付。
- “继续采购”与普通“重试采购”语义不同:替代商品匹配完成后的“继续采购”继续调用既有 `AgentRetry → BatchRetry → Create`,保留旧任务并按当前替代商品档案创建新任务;Admin 批量重试同样继续创建新任务。二者均不得改成就地更新旧任务。
- 取消订单、修改既有订单和支付仍禁止;真机重跑可能进入创建待付款订单流程,执行前必须再次取得人工授权。
- 当前设备只可重试自身最近 30 天内、服务端标记 `retryable=true` 的正式采购失败任务;列表和详情都只能发起单任务重试,不支持多选、批量或自动重试。
- 普通“重试采购”调用就地 `/reset`:复用原 `purchase_task.id`,只新增 attempt;新 attempt 使用当前有效采购规则和最新版 Agent 代码,商品、目标规格、执行规格、数量、价格、地址及既有规格决策等业务快照保持不变。
- 就地重试不恢复已经消耗的真机规格探测资格;目标规格存在但对应执行规格为空时必须拒绝,不能进入正式采购阶段。需要按替代商品或已变化业务规格重新决策时,必须走明确的新任务流程。
- 已出现 `order_submit_started` 证据,或存在不可逆时间、订单提交请求、PDD 订单号、下单时间的任务一律拒绝重试,并提示走既有“授权重新采购”流程,防止重复下单。
- `requestId` 按“任务 + 重置请求”幂等;相同请求重放返回同一 task ID 和 attempt,不重复递增。
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认和成功反馈必须说明复用原任务、新增一次执行、使用当前规则但业务快照不变、可能创建待付款订单且系统不会支付。
- `/reset` 不得把“目标规格存在但对应执行规格为空”的任务恢复到正式采购阶段;此类异常快照必须拒绝,并提示使用明确的新任务流程。
- 替代商品匹配完成后的“继续采购”继续调用 `AgentRetry → BatchRetry → Create`,按当前档案创建不同 task ID;Admin 批量重试也保持新任务语义。取消订单、修改既有订单和支付仍禁止。真机重试可能进入创建待付款订单流程,执行前必须再次取得人工授权。
## Agent 状态页手动检查任务
@@ -336,12 +341,16 @@ synchronized_at: 2026-09-04T10:22:02Z
- 自动确认必须保存来源、实际置信度和脱敏限长原因;人工确认完成后同步对应分项及主表总体状态。worker 不得覆盖已由人工改变的映射。
- 纠错不能简单执行 B→C;必须把原 A→B 记录置为 `superseded`,建立 A→C,并只处理原记录冻结的虾皮商品影响集合。
## 创建采购任务时的异步规格匹配(#148)
## 历史:创建采购任务时的异步规格匹配(#148,#215 后仅兼容存量)
- 创建任务继续同步使用已确认映射和唯一确定性 `exact_match`;只有需要外部 AI 时,任务与 `purchase_spec_match_work_item` 在同一事务创建,接口立即返回。活动工作项或 `manual_required` 未处理前,`Next`、`Claim`、`Start` 均拒绝任务。
- AI 结果只固化到采购任务快照,不自动写回虾皮商品长期规格映射。`ai_match` 必须达到设置阈值、严格属于当前候选、原因非空且写入前输入指纹未变化;否则转人工,Agent 不猜测。
- provider 网络、超时或 5xx 按 30 秒、2 分钟、10 分钟退避,最多尝试 3 次;预算内恢复会自动继续,耗尽后转 `manual_required`。Admin 可重新入队或从当前候选人工选择。
- 异步 provider 单次调用最长 60 秒,即 `min(配置超时, 60 秒)`;设置页测试连接仍使用完整配置超时。取消任务会同时终结活动工作项,输入变化会关闭旧工作项并要求重新入队。
- #215 起,新建 SYB 采购任务不再根据 PDD 档案创建 `purchase_spec_match_work_item`,也不在首次派发前调用外部 AI;规格决策统一来自首趟真机探测的当次候选。
- 部署前已存在的匹配工作项继续保留审计、恢复、重试和人工处理能力;其 AI 封闭候选、置信度、输入指纹、退避和最长 60 秒调用边界不变,但不能成为新任务跳过当次探测的依据。
## SYB 采购强制当次规格探测(#215)
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务。连续进入第二趟且当前仍有 PDD 商品页或规格面板强证据时复用当前页、不再次打开链接;手动同任务重试,或当前处于 Agent、其他应用及缺少上述强证据时,重新打开任务固化的商品 URL。复用或重开均不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
- 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。
- 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。
## Agent 手动采集替代商品(#130)
@@ -395,7 +404,7 @@ synchronized_at: 2026-09-04T10:22:02Z
- 规格入口与精确规格选择统一按“重新定位唯一目标 → 执行一次无障碍点击 → 读取新页面验证”执行;不得复用旧无障碍节点,不选择相近规格,也不在多候选时默认点击第一个。
- 无障碍点击后页面完全无变化时,只允许对解析器已确认的安全规格入口或服务端下发且唯一命中的精确规格执行一次中心手势兜底。目标必须可见、启用、边界有效,手势后仍须以规格面板强证据或精确选中证据确认结果。
- 页面发生变化但规格面板强证据不足时,不再继续手势或猜测页面,明确失败并只记录面板类型、候选数量和证据布尔值等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
- 页面发生变化但规格面板强证据不足时,默认明确失败;#243 的同商品页安全入口恢复是限定例外:确认包/Activity 未变、商品标题非空且未变、入口来源与语义未变,无面板、关闭、数量、已选、支付、订单或页面异常信号时,重新定位入口并最多执行一次受控手势。整页普通动态节点变化不单独阻断该恢复;入口缺失或显式/嵌套入口歧义仍拒绝。商品标题一致是当前上下文约束,不宣称已从页面核验 goods_id。诊断只记录入口来源、点击结果/原因、恢复结果和面板数量/布尔等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
- 规格已处于精确选中状态时不得重复点击。规格查找只在解析器唯一识别的规格面板容器内有限滚动,每次滚动后重新定位容器与目标;容器缺失、歧义、到边或验证失败均 fail-closed。
- 中心手势兜底不得用于修改/保存地址、创建或提交订单、订单详情入口以及任何支付/付款目标;正式创建订单的一次性不可逆门禁与永久禁止支付规则不变。
## PDD 商品反向关联与继续订单采购(#161)
@@ -427,3 +436,78 @@ synchronized_at: 2026-09-04T10:22:02Z
- 管理员可在设备管理对未停用设备发起“重置设备身份”。此动作不删除设备记录、不改变设备 ID、能力或已绑定的待领取任务;它立即使旧 Token 无效,并只生成一次、有效期 10 分钟的恢复码。
- 恢复码仅显示给发起操作的管理员一次,服务端只保存摘要;不得进入列表、日志、任务记录、Android 持久化或普通接口。手机操作员必须在同一安装实例的 Agent 设置中手动输入。
- 服务端只接受同一 `installId`、未过期且尚未使用的恢复码完成重新注册,成功后签发新 Token 并使恢复码失效。过期、重复使用、installId 不符或停用均明确失败;不能通过清空数据、直接改库或“吊销 Token”恢复原任务归属。
- 自 #223 起,任务创建时把与冻结 SYB 目标对应的已确认商品规格映射保存为不可变的探测指导快照,但仍不得跳过首趟真机探测。探测完成后,每个角色先验证快照映射能否按既有规范化规则唯一对应当次候选,能对应时固化当次候选原文;不能对应时只对该未解决角色执行确定性匹配,仍无结果才调用 AI。已解决角色不重复交给 AI,任一最终值仍必须逐字属于当次候选;历史映射失效、规范化后歧义或角色不符时不得复用。候选完整但无法决策时提示“已采集到当前规格,但未能确定颜色或尺码映射”,不再误报候选不存在。
## 采购商品深链入口(#232)
- Android 采购新任务(含探测与人工重试)打开商品时,优先使用指定 PDD 包名的 ACTION_VIEW,并设置 NEW_TASK 与 CLEAR_TASK,以清理旧任务栈后交付该任务链接。清理的是 Activity 返回历史,不是清除应用数据或强制停止进程。
- 直接启动失败(包括无可处理 Activity)时回退既有浏览器入口;启动请求被接受后仍由执行器验证稳定商品页面,停留首页不会仅因启动成功而判定完成。
- 清栈不用于改地址后返回、下单后回到 PDD、订单核查;采集入口继续使用既有浏览器流程,避免绕过规则中的浏览器步骤。
- 当前通用商品页面结构验证不能独立证明页面 goods_id;解析结果携带的任务 goodsId 不是页面回读证据。首次直接深链上线仍需以实际目标商品真机核对,ADB 实验不替代 Agent 上下文验证。
## SYB 批量采购设备偏好(#233)
- SYB 商品页“批量创建采购任务”按当前登录用户 ID、当前浏览器来源与 API 环境记住最近一次设备选择;主动清空也记忆为不指定设备。只保存设备 ID,不保存 Token、账号凭据或订单信息。
- 弹窗打开后先按现有在线/可选/采购能力条件加载设备,再恢复选择并以相同设备预检。记忆设备当前不可用时保留偏好并提示用户重新选择或明确清空,不静默切换设备或自动领取。
- 预检加载中、失败或记忆设备不可用时不能提交;过期预检结果不覆盖新的设备选择。服务端原有设备及采购资格校验不变。
- 偏好只作用于此入口,不影响采集、其他创建入口和采购重试。浏览器存储失败时仍允许手动操作;刷新或关闭再打开浏览器可恢复,清理浏览器数据或沿用现有退出登录清理存储行为后需重新选择。
## 客户端密钥与可编辑模块授权(#237)
以下为提交 `71f7751` 已实现的规则;2026-09-07 本机已迁移并验证管理页面可用,2026-09-08 已随 d403f3b 完成线上迁移与部署,真实客户端密钥执行闭环尚未验证。
- 仅管理员创建、查看、编辑授权或停用密钥。首版不提供密钥改名、到期、轮换、恢复启用、删除或任意接口授权。
- 模块选择复用“采集采购/采采管理”现有 12 个业务模块;分组勾选只影响当前子模块,新菜单不会自动获得授权。客户端密钥管理、系统账号权限及支付不在可授权模块中。
- 勾选模块默认只读,至少保留一个模块。读写仅开放清单中的普通写操作;同步、采集、采购、删除、导入、重解析、匹配、回写及切换当前采购规则分别独立授权,默认关闭。没有普通写接口的模块不允许勾选读写。
- 编辑既有密钥不会更换密钥,名称和前缀只读;移除模块同时移除其动作。取消保留原授权;失败保留输入;版本冲突要求刷新重开。停用不可编辑或恢复。
- 保存后新请求按最新授权校验,已经通过校验的在途请求可能完成,不追溯回滚。模块授权不是行级、店铺级或租户隔离,授予读取即允许读取该模块明确接口可返回的业务范围。
- 用户于 2026-09-07 明确接受明文风险:密钥管理及客户端接口默认兼容 HTTP/HTTPS,无需开关。HTTP 会明文传输密钥与业务数据,建议优先使用 HTTPS;兼容不改变管理员身份与模块授权边界。
- 客户端与 Admin 登录 JWT、Agent Device Token 独立。响应排除凭据及原始载荷字段;AI 设置只返回 enabled,不开放 Provider 配置读写或连接测试。设备仅开放列表,不开放身份重置、令牌或解锁接口。
- 执行动作复用既有业务门禁、幂等参数及状态机;客户端采购 batch-retry 沿用 Admin 原有语义,不等同于 Agent 就地 reset。授权重采购、支付复核、取消订单等未列入接口不开放;永久禁止付款。
- 创建响应丢失时不可找回完整密钥,应核对列表并停用可能已创建的记录,再明确创建新密钥,不能盲目自动重试。完整密钥只在创建结果弹窗内存中显示,关闭或离开页面清空,不写浏览器持久存储。
## 打开采购规格面板后按需滚动(#238)
- Android 0.9.60 / versionCode 73,代码 `58a6c1c` 起,规则 `openSpecPanel.swipeAfter` 保留格式校验与旧快照兼容,但不执行打开面板后的固定次数预滑动;不以“必须滑两次成功”作为进入规格探测/选择的条件。动作后的 `waitAfterMs` 仍生效。
- 首趟规格探测和第二趟精确选择仍使用各自既有的按需横向/纵向、有界与稳定终止策略。取消预滑动不等于不探测隐藏规格,也不等于只看首屏。目标不存在、歧义、页面证据不足或必要的有界查找失败时仍明确失败。
- 此调整覆盖所有已经安全识别打开的面板,不再仅特判 NON_SCROLLABLE_CONFIRMATION;不弱化面板验证、精确选中、地址、价格、任务租约、创建订单边界或禁止支付规则。
- 其他动作的后置滑动沿用原行为,必需滑动失败仍返回 RULE_ACTION_FAILED。旧规则 JSON 不回写、不迁移;原任务 ID、历史 attempt、商品与规格快照不变。旧 APK 行为不变,需升级 Agent;本单未改线上规则或执行 CG68 真机采购。
## 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 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
## 商品页动态变化下的采购入口恢复(#243)
实现 ec8b14a,Android 0.9.61/versionCode 74;已构建、未安装或真机验收。规格探测和正式采购共用入口函数,面板已打开时不新增点击;仍在同一商品上下文且安全入口存在时,一次重新定位手势恢复不再依赖整页完全静止。手势后仍未打开则报告入口无效果,不循环点击。恢复不能用于地址、创建订单、支付或未知面板,不修改面板分类器及正式下单边界。CG82/CG85 原首次点击未生效的底层原因尚待新日志和授权真机验证。
## 同一颜色卡片重复选中节点(#244)
实现 93aab6a,Android 0.9.62/versionCode 75;已构建并授权覆盖安装,真实采购效果待验收。仅在颜色维度完整精确目标已选中、但存在非目标已选候选时,识别同一卡片重复表达:卡片必须是滚动容器直接子级的可点击非滚动 android.view.ViewGroup,节点父链明确、可见启用且边界包含;其他冲突节点仅可为该卡片自身或其中的 ImageView。另一文字选项、另一张卡片、多个精确目标、目标未选中、父链/边界不明均不适用例外。额外核对内存源节点以拒绝被解析器按同名去重隐藏的跨卡片目标;不保存原始树。
不删除规格数字、不模糊匹配、不改变通用解析器、尺码确认、摘要兜底、入口/滚动/地址/价格/创建订单。没有非目标选中冲突时继续原判断;仅把同卡片容器/图片的重复选中表达从冲突中排除,不新增点击。
## 精确规格不可用与入口恢复诊断(#245)
实现 ca815c3,Android 0.9.63/versionCode 76;已通过单元测试并构建,未安装、未进行真实采购验收。入口恢复沿用 #243 条件,拒绝时通过 entryRecovery=rejected;reason=<固定原因码> 区分页面身份、商品上下文、面板/支付区域、数量/摘要、标题、入口来源/文案、歧义、规则别名及可用性;不放宽条件,不新增点击。
在规格面板识别出唯一完整精确候选且 available=false 时,立即返回既有 PURCHASE_SPEC_SAFE_TARGET_MISSING,提示目标颜色/尺码已售罄或当前不可选,未创建订单;已选中目标同样不能绕过不可用检查。只记录 dimension 和 exact_target_unavailable 固定结构证据,不记录规格原文或原始树。原有纵向与横向定位在此条件下本就停止,不能将本改动描述为修复“已识别售罄仍继续搜索”。未识别精确候选时继续既有有界搜索,不推断售罄,不替换规格;解析器、匹配规则、滚动预算、地址与下单流程不变。
## 改地址返回后的动态提交按钮(#248)
实现绑定 `c80c746`,Android 0.9.66 / versionCode 79;已通过 353 项 Android 单元测试并构建,尚未安装或进行真实采购验收。
- 地址回读与最终提交按钮定位分开判断。旧“提交订单”“现在买,仅”“确认购买”识别继续优先;存在旧文案候选但歧义时不使用结构补充选择其中一个。地址确实缺失时保留既有有限恢复;地址及采购面板上下文已确认后,不因底部按钮文字变化而滚动规格列表。按钮缺失或不唯一使用 `PURCHASE_SUBMIT_TARGET_AMBIGUOUS` 提示按钮问题,不误报地址保存超时。
- 结构补充只属于当前执行器中已完成地址修改后的返回流程,不能由其他页面或旧执行的地址证明启用。当前须为 PDD、地址后缀按既有规则回读唯一、地址入口唯一,并存在唯一数量输入或已选摘要;通过地址入口与该证据的最近共同祖先确定整个采购弹层,不把颜色/尺码滚动区域当成弹层。排除全屏大祖先、地址编辑、已知支付 Activity 及订单结果/支付页面,后续定位要求 Activity 与地址返回时一致。
- 旧文案无候选时,只接受弹层底部固定区域内一个独立、可见启用、非滚动的可点击容器;不在规格滚动区内选择按钮,不取控件树末项,不使用固定屏幕点击坐标。文字与人民币金额子节点合并为一个按钮;支持如“复购价,”及非支付展示文字变化,但单独价格、多个操作按钮或支付/付款/地址/返回等非提交动作均不放行。
- 地址返回检查、最终复核和提交共用目标定位。结构路径保留原文字节点为重新定位锚点,由已有无障碍点击重新查找其可点击父级;不新增下单手势兜底。最终复核成功后才允许该结构路径提交;提交前再次检查路径、类别、文字与边界,发生变化即停止;复核失败清除本地结构提交锚点。
- 既有规格、数量、价格和地址复核,任务租约、服务端/本地不可逆边界及单次提交规则不变。结构识别不支持支付,不增加采购员操作、配置或审批;不改变 Admin、接口、数据库、规则 JSON、通用解析器及滑动执行器。原始控件树、整屏截图、地址全文和凭据不得落盘或上传。
+28 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
wiki_revision: 0bb8d63fe2a3c6b1021bfa18800f5062de726868
synchronized_at: 2026-08-31T16:06:55Z
wiki_revision: 835494c4a63a1494601658561fde1ab76657be3d
synchronized_at: 2026-09-05T07:17:05Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -24,6 +24,32 @@ T01 已建立可执行的三端骨架。建议从仓库根目录运行统一脚
- 不得仅为设置编码重复启动一层 PowerShell;嵌套进程会增加启动时间、转义复杂度和错误定位成本。
- 代码发现优先使用项目配置的代码图工具;检索字符串、配置和非代码文件,或图工具不足时使用 `rg`。
### PowerShell 语法与外部命令
- Windows 命令不得默认套用 Bash 语法;复杂正则优先先赋给变量或使用 `rg -e`,避免在多层引号中继续嵌套。
- 多行 Python 或 JSON 正文使用单引号 PowerShell here-string,避免 `$()`、反引号和变量被 PowerShell 提前展开:
```powershell
$script = @'
print("保持原文")
'@
$script | python -
```
- `foreach`、`if` 等语句块应保留在同一个 PowerShell 解析上下文中;需要收集表达式结果时使用数组表达式:
```powershell
$items = @(foreach ($path in $paths) {
if (Test-Path -LiteralPath $path) { Get-Item -LiteralPath $path }
})
```
- `rg` 使用真实目录配合 `-g/--glob`,不要把 Bash 风格通配路径作为目录参数:
```powershell
rg -n -g '*.md' 'sync --check' docs
```
### 文件编码与控制台输出
文件解码和控制台输出是两个边界。读取 UTF-8 文本时,在命令支持的情况下显式指定字面路径与编码:
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Common-Changes
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Common-Changes.-
wiki_revision: b7d5520da954c544248e979c4ababb86d2784a43
synchronized_at: 2026-08-27T09:38:36Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:12Z
<!-- gitea-wiki-mirror:end -->
# 常见修改指南
+21 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: a8d4790811aa825c78830cb9c71c6b21723e4584
synchronized_at: 2026-08-28T03:35:05Z
wiki_revision: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
synchronized_at: 2026-09-09T03:16:51Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -85,3 +85,22 @@ sqlite3 -readonly agent-diagnostics.db "SELECT task_id,reason,color_row_count,co
```
读取时记录设备、Agent 版本、任务号和规则快照;工单只回写查询得到的脱敏聚合数值。读取完成后删除本地导出副本。正式 APK 若不允许 `run-as`,停止排查并确认安全的只读诊断出口,不通过放宽应用安全配置或上传完整数据库绕过。
## Android 采购规格入口本地诊断(#249)
实现绑定 `99faf5a`,Agent `0.9.67`(versionCode 80)。单元测试与 Debug 构建已通过,新增版本真机留存/读取尚待验证;本节不属于上文既有实测结论。
采购执行器启动后,在应用私有 `files/purchase_diagnostics/<taskId>_<attemptId>.jsonl` 保存白名单结构化入口诊断。元数据包含 taskId、attemptId、deviceId、agentVersion、phase、ruleHash(当前任务规则快照 SHA-256)、timestamp 和 elapsedMs。现有任务接口只有 attemptId,没有 attemptNumber;用 attemptId 对照服务端历史,不推算重试序号。
证据仅包含入口来源枚举、候选数、点击结果/原因枚举、等待轮数、面板结构布尔值/计数,以及受控恢复是否执行及固定拒绝原因。没有商品标题、规格原文、地址、手机号、Cookie、Token、链接、原始控件树或截图。entryWaitMillis 是该等待循环累计的计划等待量;elapsedMs 才是自本次采购执行器诊断起点计量的实际耗时,timestamp 是后台写入时间。
复用现有单线程诊断队列异步写入,写入/排队失败不改变采购结果;不新增点击、滑动、等待或采购门禁。最多保留最近 5 个 attempt 文件、每文件最后 128 条事件,超过 7 天的文件在服务启动或下次写入时清理。异常断电或存储故障仍可能丢失诊断,不能将日志缺失当成动作未执行;不上传 Admin,不增加服务端接口。
仅对允许 run-as 的 Debug APK,在设备已连接且选定准确序列号后只读提取:
```powershell
adb -s <device-serial> shell run-as cn.ilapage.goauto.agent ls files/purchase_diagnostics
adb -s <device-serial> exec-out run-as cn.ilapage.goauto.agent cat files/purchase_diagnostics/<taskId>_<attemptId>.jsonl
```
先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements-Overview
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Product-Requirements-Overview.-
wiki_revision: 0d9924b4566e567edf167ba678fedde28a41cfd3
synchronized_at: 2026-08-28T02:27:12Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:20Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览与当前 MVP
+141 -33
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: a48b6a104c9b96834bbe122a226ba50baf8e4f93
synchronized_at: 2026-09-04T10:22:06Z
wiki_revision: 49dec4c35da793a32822a82e00f3b657757b6d44
synchronized_at: 2026-09-07T13:35:25Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -75,7 +75,7 @@ POST /api/admin/v1/syb-products/reparse-batch
PATCH /api/admin/v1/syb-products/{productId}/correction
```
`import` 仅允许管理员调用。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
`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`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
@@ -405,7 +405,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
- `executionMode` 只能是 `rehearsal` 或 `live`,创建后不可修改。
- 正式 `live` 任务必须引用一条 `syb_product`;无副作用的 `rehearsal` 可以不引用 SYB。
- 创建时固化 SYB、蝦皮订单号、虾皮商品、PDD 商品、目标规格、映射规格、数量、价格区间、币种、URL、`goods_id`、规则和可选 PDD 账号引用。正式任务的 `shopeeOrderNoSnapshot` 来自创建时的 `syb_product.order_code`;同一订单号可对应多条任务,之后来源商品修改不会改写任务快照。
- 创建时固化 SYB、蝦皮订单号、虾皮商品、PDD 商品、目标规格、数量、价格区间、币种、URL、`goods_id`、规则和可选 PDD 账号引用。新建 SYB 任务的执行映射初始为空,首趟当次 PDD 规格探测决策成功后才固化 `mappedColor` / `mappedSize`;正式任务的 `shopeeOrderNoSnapshot` 来自创建时的 `syb_product.order_code`,之后来源商品修改不会改写任务快照。
- Android 无法可靠读取当前 PDD 账号,因此 `pddAccountId` 和 `pddAccountRefSnapshot` 均可空。已知账号时参与账号级串行,未知时不阻止任务。
- 地址后缀由任务 ID 唯一确定为 `_cg{taskId}`;#34 创建任务时必须在同一事务内回写快照。
- 一个任务最多对应一个 PDD 订单;重新采购必须新建任务,旧任务和旧订单保留。
@@ -415,7 +415,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
| 状态 | 含义 | 是否占用 SYB 活动槽 |
|---|---|---|
| `pending` | 待执行 | 是 |
| `spec_probe_pending` | 第一趟探测结束,待服务端固化规格并重新派发 | 是 |
| `spec_probe_pending` | 第一趟探测结束,服务端规格匹配中;当前设备保留同任务连续流程且不得领取其他任务 | 是 |
| `running` | Agent 执行中 | 是 |
| `rehearsal_completed` | 演练安全结束,未改地址、未创建订单 | 否 |
| `order_submit_started` | 不可逆标记已落库,只能核单,禁止再次点击 | 是 |
@@ -472,7 +472,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
|---|---:|---:|---:|---:|
| `openProduct` | 是 | 是 | 是 | 否 |
| `verifyProduct` | 是 | 是 | 否 | 否 |
| `openSpecPanel` | 是 | 是 | 是 | 否 |
| `openSpecPanel` | 是 | 是 | 兼容读取、不执行(0.9.60+,见 #238) | 否 |
| `selectSpec` | 是 | 是 | 是 | 否 |
| `setQuantity` | 是 | 是 | 否 | 否 |
| `verifyUnitPrice` | 是 | 是 | 否 | 否 |
@@ -486,7 +486,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
- 文字候选按控件文字或内容描述**精确匹配**;候选合并后必须唯一命中。点击动作只允许点击唯一文字节点或其最近的可点击父容器,不允许模糊匹配、猜测相近候选、改点兄弟节点。
- 动作 `textAliases` 不能包含地址修改、创建/提交订单、订单号或支付相关文字,防止用安全 action 绕过危险动作类型和能力门禁。只读识别字段使用独立校验:允许订单和支付证据,仍拒绝修改地址、收货地址,并沿用各字段声明的数量、长度、去重和空白限制。
- `waitAfterMs` 表示动作成功后的等待时间,范围为 0~30000 毫秒;省略时为 0。
- `swipeAfter` 表示动作成功后执行一个有限滑动计划。`direction` 只能为 `up` / `down` / `left` / `right`,`count` 为 1~10,`durationMs` 为 100~2000,`intervalMs` 为 0~5000 且省略时为 0。
- `swipeAfter` 通常表示动作成功后执行一个有限滑动计划;Android 0.9.60+ 的 `openSpecPanel` 例外,只兼容读取而不执行预滑动(见 #238)。`direction` 只能为 `up` / `down` / `left` / `right`,`count` 为 1~10,`durationMs` 为 100~2000,`intervalMs` 为 0~5000 且省略时为 0。
- 未在矩阵中授权的 action/参数组合、未知字段、空候选和越界值一律拒绝。`updateShippingAddress`、`createOrder`、`readOrderResult` 等正式动作在其独立高风险契约完成前不接受上述参数。
- 旧的仅含 `actions[].type` 的规则继续有效:候选使用 Agent 内置语义,等待为 0,不执行动作后滑动。
- 服务端保存创建任务时收到的完整原始规则快照;规则后来更新为规则 B,不会改变已有任务中的规则 A 快照。
@@ -511,7 +511,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
| `GET` | `/api/admin/v1/purchase-tasks/{taskId}` | 只读任务详情与 attempt 历史;不返回规则原文、Token、凭据、完整地址、控件树或截图 |
| `POST` | `/api/admin/v1/purchase-tasks` | `requestId`;单条创建 |
| `POST` | `/api/admin/v1/purchase-tasks/stock` | 创建备货采购;`requestId`、`executionMode`、`pddProductId`、可选 `deviceId` / `pddAccountId`、`color`、可选 `size`、`quantity`、`minUnitPriceCent`、`maxUnitPriceCent`。服务端使用内置规则并从所选颜色归档派生参考价;相同 `requestId` 幂等返回原任务 |
| `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回采购是否可创建、价格区间、原因和下一步,并独立返回 PDD 采集资格 `collectionEligible` / `collectionDisabledReason` 与规格匹配资格 `aiMatchEligible` / `aiMatchDisabledReason`,不创建任务。只有目标颜色和尺码已保存为确认映射,且共同命中最近一次成功或部分成功采集中的完整可售 SKU 组合时,`eligible` 才为 `true`;只在内存中得到的确定性结果不算采购就绪 |
| `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回采购是否可创建、价格区间、原因和下一步,并独立返回 PDD 采集资格 `collectionEligible` / `collectionDisabledReason` 与长期规格匹配资格 `aiMatchEligible` / `aiMatchDisabledReason`,不创建任务且不调用 AI Provider。自 #215 起,长期映射缺失或失效不再单独使 `eligible=false`;新任务会在首趟当次 PDD 页面探测中决定执行规格 |
| `POST` | `/api/admin/v1/purchase-tasks/batch-spec-match` | `sybProductIds`(1~100);仅处理服务端预检 `aiMatchEligible=true` 的明细,其他明细按具体禁用原因返回 `skipped`,并逐条返回 `auto_confirmed` / `pending` / `failed` / `skipped`。资格要求 SYB 采购规格解析成功、蝦皮与 PDD 关联完整、PDD 当前规格可用,且最近一次成功或部分成功采集存在完整可售 SKU 组合证据;未人工确认的 `parse_status=uncertain` 不进入自动确认,`manuallyConfirmed=true` 与 `parse_status=success` 同等视为可信规格。唯一确定性 `exact_match` 不调用 Provider,直接通过独立路径保存为 `confirmed`;其余结果只有 `ai_match` 置信度达到 `autoConfirmMinConfidence`、理由非空、返回值属于当前候选且颜色+尺码命中同一个可售 SKU 组合时才保存为 `confirmed`。低置信度、无效组合和 Provider 异常不改写现有映射 |
| `POST` | `/api/admin/v1/purchase-tasks/batch` | 批次 `requestId`、`sybProductIds`(1~100)和可选 `deviceId`;每条派生稳定幂等键并独立创建,部分失败不回滚成功项 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/authorize-repurchase` | 一次性授权;创建新任务后自动消耗 |
@@ -529,17 +529,17 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列
| 方法 | 路径 | 说明 |
|---|---|---|
| `GET` | `/api/agent/v1/purchase-tasks/next` | 返回与设备能力兼容的指定任务或空闲任务 |
| `GET` | `/api/agent/v1/purchase-tasks/next` | 优先返回当前设备的运行任务;存在 `spec_probe_pending` 时返回同一等待任务以阻止其他任务插队,否则返回能力兼容的指定任务或空闲任务 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/claim` | `requestId` 原子领取,并建立设备/可选账号租约 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/start` | 创建不可变 `taskAttemptId` |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务永远拒绝 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务和 `spec_probe` attempt 永远拒绝 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 |
创建阶段需要外部 AI 的任务会先以 `pending` 与持久匹配工作项同事务创建并立即返回 Admin。工作项处于 `pending`、`running`、`retry_wait` 或 `manual_required` 时,`next` 不返回该任务,直接调用 `claim` 或 `start` 也会返回状态冲突。匹配完成后 Agent 仍只接收服务端固化的精确 PDD 原始标签;Android 接口和请求体不新增 AI、候选或人工决策字段。
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。连续第二阶段仅在当前 PDD 页面仍有商品页或规格面板强证据时复用首趟页面、不再次打开浏览器链接;手动同任务重试,或当前为 Agent、其他应用及缺少上述强证据时,Android 重新打开任务 `urlSnapshot`。两种路径都不以标题、goodsId 或页面指纹做严格同页校验,仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。慢路径第一趟提交规格后释放设备与已知账号租约,任务进入 `spec_probe_pending`;服务端先使用已确认人工映射,否则对实时/档案可选规格做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。第二趟只会收到服务端已固化的精确 PDD 原始标签;Agent 只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放数据库租约和已知账号运行守卫并进入 `spec_probe_pending`,但服务端调度与 Agent 必须把当前设备保留给同一采购流程:`next` 返回该等待任务,Agent 只轮询等待,不领取其他采购或采集任务。服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 复用首趟仍打开的页面,只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
任务 payload 新增必传布尔字段 `specResolutionAllowed`,它是 Android 是否可以把一次 `PURCHASE_SPEC_TARGET_NOT_VISIBLE` 转为 `spec_probe_completed` 的唯一资格事实。服务端仅在 `taskType=syb_order`、规则声明 `purchase.spec-probe.v1`、没有固化 `SpecDecisionRequestID` 且规格来源允许既有慢路径时返回 `true`;`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。普通就地重试保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格。Android 不得根据映射是否非空、执行 phase、错误文字或本地判断扩大资格。
任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。普通 Agent 重试使用就地 `/reset`,保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;映射不完整时必须拒绝,不能进入正式采购阶段。只有替代商品“继续采购”或 Admin 批量重试创建的新任务才按 #215 重新取得一次探测资格。Android 不得根据映射是否非空、错误文字或本地判断扩大资格。
Android 规格失败使用五个稳定阶段:`PURCHASE_SPEC_TARGET_NOT_VISIBLE`、`PURCHASE_SPEC_TARGET_AMBIGUOUS`、`PURCHASE_SPEC_SAFE_TARGET_MISSING`、`PURCHASE_SPEC_CLICK_FAILED` 和 `PURCHASE_SPEC_SELECTION_UNCONFIRMED`。`PURCHASE_SPEC_CLICK_FAILED` 的 `errorMessage` 只允许稳定子原因 `root_unavailable`、`target_stale`、`no_clickable_ancestor`、`action_click_false` 或 `unknown`;其他阶段的消息不得包含规格原文、坐标、控件树或截图。只有 `PURCHASE_SPEC_TARGET_NOT_VISIBLE && specResolutionAllowed=true` 可以提交规格探测,其他四态直接提交真实失败,服务端原样保留稳定阶段/子原因。旧 Agent 在资格已用尽后再次提交 `spec_probe_completed` 时,服务端以 `PURCHASE_SPEC_REPROBE_REJECTED` fail-closed,释放租约并保留第一次规格决策,不再冒充新的选择根因或再次派发。无匹配、候选不完整、歧义或 Provider 异常同样使任务失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。
@@ -617,12 +617,12 @@ Content-Type: application/json
- 响应返回 `taskId`、`attemptNumber`、`status` 和可选的 `replayed`,不返回规则快照、URL、Token、控件树或截图。
- 设备离线、任务非终态、设备忙、规则不可用或同商品存在活动任务时返回明确冲突,不支持离线排队。
## Agent 受控采购重试(#95、#157)
## Agent 受控采购重试(#95、#157、#217、#225)
普通失败任务的“重试采购”改为就地重跑:
替代商品匹配完成后的“继续采购”调用新任务接口;普通失败任务的“重试采购”使用下方同任务重置接口:
```http
POST /api/agent/v1/purchase-tasks/{taskId}/reset
POST /api/agent/v1/purchase-tasks/{taskId}/retry
Authorization: Bearer <device-token>
Content-Type: application/json
@@ -634,37 +634,35 @@ Content-Type: application/json
```json
{
"data": {
"taskId": 12,
"taskNo": "CG-12",
"attemptNumber": 2,
"status": "pending",
"sourceTaskId": 12,
"sourceTaskNo": "CG-12",
"taskId": 13,
"taskNo": "CG-13",
"replayed": false
}
}
```
- 原任务必须属于当前 Device Token、在最近 30 天内、状态为 `failed`,且是分配给该设备的正式 SYB 采购任务;跨设备或超期按任务不存在处理。
- 任务不得存在 `irreversibleAt`、`orderSubmitRequestId`、PDD 订单号或下单时间。已有任何不可逆证据时返回 `PURCHASE_RETRY_UNSAFE`,提示走“授权重新采购”,不得恢复为待执行。
- 服务端在同一事务锁定任务和设备,确认同一 SYB 商品没有更新任务、设备在线且空闲,然后读取当前服务端采购规则,重新校验 schema、动作安全边界与设备能力。
- 成功时复用原 `purchase_task.id`,只刷新 `ruleSnapshot`、`ruleType`、`ruleSchemaVersion`、`requiredCapabilities`,清除错误、租约和运行守卫并恢复 `pending`。商品、Target、Mapped、价格保护、数量、地址后缀及其他业务快照逐字段保持不变。
- 每次受理创建该任务的新 `pending` attempt 并记录规则哈希;Start 复用该 attempt 转为 `running`,不会重复创建执行记录。相同 `requestId` 重放返回相同 attempt 且 `replayed=true`;不同请求可在任务再次失败后继续重试,不限制次数。
- 采购详情新增 `attemptCount`、可选 `lastFailureCode` 和 `lastFailureMessage`,供 Agent 显示已尝试次数与上次失败原因;不返回规则快照、Token、地址、控件树或截图。
- Android 仍只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认文案必须说明任务号不变、使用最新规则重跑、可能产生待付款订单且系统不会支付。
- 规则无效、设备离线/忙、能力不匹配、任务状态变化或同一 SYB 商品已有更新任务时,服务端明确拒绝且不得部分修改任务。
- 来源任务必须属于当前 Device Token、在最近 30 天内、状态为 `failed`,且是分配给该设备的正式 SYB 采购任务;跨设备或超期按任务不存在处理。
- 来源任务不得存在 `irreversibleAt`、`orderSubmitRequestId`、PDD 订单号或下单时间。已有任何不可逆证据时返回 `PURCHASE_RETRY_UNSAFE`,提示走“授权重新采购”,不得创建新任务。
- `AgentRetry → BatchRetry → Create` 保留来源失败任务并创建不同 `purchase_task.id` 的新任务;新任务重新读取当前 SYB、虾皮/PDD 档案、当前采购规则、价格保护和设备能力,重新生成地址后缀,不继承来源任务的旧规格决策。
- 新 SYB 任务按 #215 固定从 `spec_probe` 开始。相同 `requestId` 重放返回同一新任务且 `replayed=true`;不同 requestId 再次请求受同一 SYB 商品最新任务和设备并发门禁约束。
- 本新任务接口只供替代商品“继续采购”;确认和成功反馈必须说明保留来源任务、按当前替代商品档案创建新任务、可能产生待付款订单且系统不会支付。
- 规则无效、当前档案或价格不合格、设备离线/忙、能力不匹配、任务状态变化或同一 SYB 商品已有更新任务时,服务端明确拒绝且不得部分创建。
- Admin 批量重试继续使用相同的新任务语义;替代商品“继续采购”仍在 AgentRetry 前额外验证替换分项与继续采购资格。
既有新建任务接口保留原语义:
普通失败任务的“重试采购”调用同任务重置接口:
```http
POST /api/agent/v1/purchase-tasks/{taskId}/retry
POST /api/agent/v1/purchase-tasks/{taskId}/reset
Authorization: Bearer <device-token>
Content-Type: application/json
{"requestId":"<uuid>"}
```
- `/retry` 的 `AgentRetry → BatchRetry → Create` 行为不变:保留来源失败任务并创建新任务,返回 `sourceTaskId`、`sourceTaskNo`、新 `taskId`、新 `taskNo` 和 `replayed`。
- Android 普通“重试采购”不再调用 `/retry`;只有 #132 替代商品规格匹配完成后的“继续采购”继续调用它。Admin 批量重试行为也不变。
- “继续采购”会重新解析替代商品、规格映射与价格并生成新地址后缀;这与普通失败任务保持快照的就地重跑不可互换。
- `/reset` 只允许当前设备的安全失败任务且不得存在不可逆证据或更新任务;它复用原 `purchase_task.id`、新增 attempt、刷新当前有效规则,并保持商品、目标/执行规格、数量、价格、地址和规格决策等业务快照不变。
- 目标颜色存在但映射颜色为空,或目标尺码存在但映射尺码为空时,必须返回 `PURCHASE_SPEC_MAPPING_REQUIRED`,不得创建 `purchase` attempt 或下发正式采购 payload。相同 `requestId` 重放返回同一 task ID 和 attempt,不重复递增;Android 必须明确提示复用原任务和新的 attempt 序号。
- 两个入口都不执行支付。真机调用可能创建待付款订单,必须先取得人工授权。
## Agent 任务记录范围与同步(#99)
@@ -867,3 +865,113 @@ X-GoAuto-Device-Recovery-Code: <one-time-code>
仅管理员可以对未停用的既有设备发起身份重置。服务端立即使旧 Device Token 无效,并生成 10 分钟内仅能使用一次的恢复码;恢复码只在该管理员操作的响应中返回一次,服务端仅保存不可逆摘要,管理端设备列表、日志、任务接口和 Android 本地持久化均不得保存或返回原文。管理员将恢复码经受控人工渠道输入同一安装实例的 Agent 设置页。
Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口。服务端必须同时校验同一 `installId`、未停用状态、恢复码摘要、未过期和未使用;成功后使用原 `deviceId` 写入新 Token 摘要并返回一次新 Token,清除恢复码摘要和有效期。旧 Token 与恢复码都立即失效,已分配的 pending 采集或采购任务保持原 `deviceId`,不创建替代设备记录。缺少或错误恢复码仍为 `DEVICE_INSTALL_ID_CONFLICT`;过期码为 `DEVICE_RECOVERY_EXPIRED`;停用设备为 `DEVICE_DISABLED`。
自 #223 起,新建 SYB 任务在保持 `mappedColor` / `mappedSize` 为空和首趟 `spec_probe` 不变的同时,把创建时与目标规格对应的 confirmed 商品映射冻结为仅供服务端决策的指导快照。服务端收到当次候选后按角色验证该快照:只有规范化后唯一对应当次候选时才复用,并固化当次候选原文;否则该角色继续执行确定性匹配,仍未解决才把该角色及其封闭候选交给 AI。已解决角色不得重复发送给 AI,最终颜色和尺码仍须逐字属于各自当次候选。任务决策快照通过 `roleSources` 记录每个角色的 `manual_mapping` / `exact_match` / `ai_match` 来源;任务级 `specSource` 使用现有枚举汇总,不新增 Agent 决策权限。
## 客户端 API 与管理员密钥管理(#237)
实现基线 `71f7751`;以下接口已通过隔离测试;2026-09-07 本机 MySQL 迁移及管理员通过 HTTP 读取列表、模块目录已验证,真实密钥创建/编辑/停用及业务访问仍未联调,线上尚未部署。Android 接口不变。
### 管理接口
前缀 `/api/admin/v1/client-keys`,要求 Admin JWT 和 admin 角色,默认接受 HTTP/HTTPS,响应 `Cache-Control: no-store`。
| 方法与相对路径 | 输入 | 成功 data |
|---|---|---|
| GET 空路径 | page,固定每页 20 | items、total、page、pageSize |
| GET /modules | 无 | 模块数组:key、title、group、writable、actions |
| POST 空路径 | name(1~80 字符)、grants | key 元数据与仅本次返回的 secret |
| PATCH /:keyId/grants | version、grants | 更新后的元数据 |
| POST /:keyId/disable | version | 停用后的元数据 |
授权示例:`{"module":"pdd_products","write":false,"actions":[]}`;grants 为非空数组。元数据包括 id、name、prefix、enabled、version、grants、createdBy、updatedBy、createdAt、updatedAt、lastUsedAt,不返回摘要或完整密钥。管理请求只接受单个 JSON 对象、拒绝未知字段、最大 64 KiB。成功 code=200;无效输入 422、不存在 404、授权版本冲突或已停用 409。
### 客户端访问与错误语义
- 前缀 `/api/client/v1`,只接受 `Authorization: Bearer <客户端密钥>`,不接受 Cookie 或 URL 凭据,不与 JWT、Device Token 通用。
- 根据用户 2026-09-07 的明确确认,管理与客户端接口在所有环境默认接受 HTTP/HTTPS,不设置协议开关,也不依赖 X-Forwarded-Proto 或 GOAUTO_TRUST_FORWARDED_PROTO。HTTP 明文传输密钥及业务数据,优先使用 HTTPS;不影响其他接口各自的协议要求。
- 不再因 HTTP 返回 426;401 为缺失、无效或停用密钥;403 为模块/动作未授权;400 为查询参数携带凭据;503 为认证或审计暂不可用。业务错误沿用各既有接口。
- 一般请求体最大 16 MiB;响应只支持有限 JSON(32 MiB),递归过滤凭据与原始载荷字段。不支持直接流式/二进制文件接口。响应不可序列化或超限时返回 502;业务可能已执行,必须先核对结果,不要自动重试。
- 通过密钥认证的请求由服务端生成 `X-Client-Request-Id` 关联审计;它不是业务幂等键,原业务接口要求的 requestId 等字段仍须提供。允许请求必须先落审计意图;完成状态更新失败可留下 status=0。无效密钥没有 key_id 关联审计;拒绝授权的 403 审计为尽力记录。
- GET `/ai-matching-settings` 只返回 `data.enabled`。POST `/ai-matching-settings/resolve` 接受 targetColor、targetSize、colors、sizes;每组最多 200 项,每个值最多 255 字符,总请求最大 64 KiB;确定性优先,必要时使用当前 Provider,返回 mappedColor、mappedSize、source;不保存映射、不创建任务,无法可靠匹配返回 422 安全提示。
### 明确开放的接口清单
下表路径均相对 `/api/client/v1`;普通业务请求字段沿用本文对应 Admin 业务契约。read=选中模块,write=模块读写,其他值均需 actions 逐项授权。没有列出的 Admin 接口不能用客户端密钥调用;新增 Admin 路由不会自动开放。
| 方法 | 路径 | 模块键 | 能力 |
|---|---|---|---|
| POST | `/ai-matching-settings/resolve` | ai_matching | match |
| GET | `/devices` | devices | read |
| GET | `/pdd-products` | pdd_products | read |
| GET | `/pdd-products/:productId` | pdd_products | read |
| POST | `/pdd-products` | pdd_products | write |
| PATCH | `/pdd-products/:productId` | pdd_products | write |
| GET | `/shopee-products` | shopee_products | read |
| GET | `/shopee-products/:productId` | shopee_products | read |
| POST | `/shopee-products` | shopee_products | write |
| PATCH | `/shopee-products/:productId` | shopee_products | write |
| POST | `/shopee-products/:productId/link-pdd` | shopee_products | write |
| POST | `/shopee-products/:productId/specs/values` | shopee_products | write |
| PUT | `/shopee-products/:productId/specs/mapping` | shopee_products | write |
| DELETE | `/shopee-products/:productId/specs/values` | shopee_products | delete |
| DELETE | `/shopee-products/:productId/specs/mapping` | shopee_products | delete |
| POST | `/shopee-products/batch-delete` | shopee_products | delete |
| POST | `/shopee-products/:productId/specs/mapping/auto-match` | shopee_products | match |
| POST | `/shopee-products/:productId/specs/mapping/confirm` | shopee_products | match |
| GET | `/syb-products` | syb_products | read |
| GET | `/syb-products/:productId` | syb_products | read |
| PATCH | `/syb-products/:productId/correction` | syb_products | write |
| POST | `/syb-products/:productId/reparse` | syb_products | reparse |
| POST | `/syb-products/reparse-batch` | syb_products | reparse |
| GET | `/syb-products/sync-runs` | syb_sync_runs | read |
| GET | `/syb-products/sync-runs/:runId` | syb_sync_runs | read |
| POST | `/syb-products/import` | syb_sync_runs | sync |
| GET | `/syb-inner-codes` | syb_inner_codes | read |
| GET | `/syb-inner-codes/:recordId` | syb_inner_codes | read |
| GET | `/syb-inner-codes/match-jobs/:jobId` | syb_inner_codes | read |
| GET | `/syb-inner-codes/apply-batches/:batchId` | syb_inner_codes | read |
| POST | `/syb-inner-codes/rematch` | syb_inner_codes | match |
| POST | `/syb-inner-codes/import` | syb_inner_codes | import |
| POST | `/syb-inner-codes/batch-delete` | syb_inner_codes | delete |
| POST | `/syb-inner-codes/apply-preview` | syb_inner_codes | writeback |
| POST | `/syb-inner-codes/apply` | syb_inner_codes | writeback |
| POST | `/syb-inner-codes/:recordId/recheck` | syb_inner_codes | match |
| GET | `/syb-shops` | syb_shops | read |
| POST | `/syb-shops` | syb_shops | write |
| PATCH | `/syb-shops/:shopId/name` | syb_shops | write |
| PATCH | `/syb-shops/:shopId/enabled` | syb_shops | write |
| DELETE | `/syb-shops/:shopId` | syb_shops | delete |
| GET | `/collection-rules` | collection_rules | read |
| POST | `/collection-rules` | collection_rules | write |
| PATCH | `/collection-rules/:ruleId` | collection_rules | write |
| DELETE | `/collection-rules/:ruleId` | collection_rules | delete |
| GET | `/purchase-rules` | purchase_rules | read |
| GET | `/purchase-rules/current` | purchase_rules | read |
| POST | `/purchase-rules` | purchase_rules | write |
| PATCH | `/purchase-rules/:ruleId` | purchase_rules | write |
| DELETE | `/purchase-rules/:ruleId` | purchase_rules | delete |
| PUT | `/purchase-rules/current` | purchase_rules | activate |
| GET | `/collection-tasks` | collection_tasks | read |
| GET | `/collection-tasks/:taskId` | collection_tasks | read |
| POST | `/collection-tasks` | collection_tasks | collect |
| POST | `/collection-tasks/batch` | collection_tasks | collect |
| POST | `/collection-tasks/:taskId/reset` | collection_tasks | collect |
| DELETE | `/collection-tasks/:taskId` | collection_tasks | delete |
| GET | `/purchase-tasks` | purchase_tasks | read |
| GET | `/purchase-tasks/:taskId` | purchase_tasks | read |
| POST | `/purchase-tasks/batch-preview` | purchase_tasks | purchase |
| POST | `/purchase-tasks` | purchase_tasks | purchase |
| POST | `/purchase-tasks/batch` | purchase_tasks | purchase |
| POST | `/purchase-tasks/batch-retry` | purchase_tasks | purchase |
| POST | `/purchase-tasks/stock` | purchase_tasks | purchase |
| GET | `/ai-matching-settings` | ai_matching | read |
### openSpecPanel 后置滑动兼容与诊断(#238)
版本边界:Android 0.9.60 / versionCode 73,代码 `58a6c1c`。不修改 JSON schema、能力标识、任务接口或已有快照哈希。`openSpecPanel.swipeAfter` 仍按 direction/count/durationMs/intervalMs 原约束校验;解析成功后不执行该准备性滑动,`waitAfterMs` 保留。其他动作后置滑动沿用旧执行语义。旧 Server 可继续下发原快照;旧 APK 仍按原策略执行,不能将本契约描述当作旧设备已获得兼容。
规格探测与精确选择自行负责按需有界滚动,原始候选、精确点击和选中复核不变。跳过预滑动不作为规格探测成功或订单创建证据。
本地 `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,现有结果提交字段不变。
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Delivery-Issues
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.-
wiki_revision: 0eed28dd5dc629ce66071833d5fefbdf24d567d3
synchronized_at: 2026-08-24T02:35:58Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:28Z
<!-- gitea-wiki-mirror:end -->
# 当前 MVP 交付工单索引
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: OnePlus-Real-Device-Acceptance
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance.-
wiki_revision: 5b74a3b915cda6307cd59087bf9ce295f4c5e806
synchronized_at: 2026-08-21T08:08:57Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:32Z
<!-- gitea-wiki-mirror:end -->
# 一加真机验收记录
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: PDD-Detail-Rule-Migration-Analysis
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/PDD-Detail-Rule-Migration-Analysis.-
wiki_revision: 1fe9ed083c4fc565a50b4186571233d1dfab58a7
synchronized_at: 2026-08-17T03:28:38Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:37Z
<!-- gitea-wiki-mirror:end -->
# PDD 商品详情采集规则迁移分析
+31 -7
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: SYB-ERP-Interface-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
wiki_revision: f596931468caf524cbd9d90b3c299b32a8cf8228
synchronized_at: 2026-08-28T02:29:07Z
wiki_revision: a4f4ab200af052bab7ffbabe267238528bcd7cf9
synchronized_at: 2026-09-07T07:02:57Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
@@ -228,9 +228,20 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整
读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成
稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`;
网络/业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。
`[必须,#235]` 当天跨页重复 ID 纳入上述最多 3 次列表快照尝试(含首次),
不增加另一层重试次数。发现跨页重复后丢弃本次列表,从预检总数和第一页重新开始,
使用全新 ID 集合;最后一次仍重复时直接失败,不得去重后按成功或降级数据保存。
已经完成的历史日期保持其已有结果,不重复拉取;历史日期重复不适用此恢复。
每页先检查非法 ID 和页内重复,再检查跨页重叠;同页同时存在页内重复和跨页重叠时
仍作为硬错误停止。原有总数漂移/短页的合法唯一列表降级保存条件保持不变。
重复诊断只记录日期、当天尝试序号、首次/当前页码与行号、start、pageSize、
expectedTotal 和已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
### 4.4 统一日期范围同步与覆盖游标
GoAuto 同步记录页的立即同步允许管理员和采购员(purchaser)使用(#236),固定覆盖昨天和今天;其他已登录角色仅可查看记录。复用既有同步互斥、日期校验、启用店铺筛选及操作人审计,不授予店铺配置、凭据或定时任务管理权限。权限代码发布并完成启动对账后生效。
页面只有一个同步入口,操作员确认工具条上的开始日和结束日后发起。首次打开页面
固定默认昨天到今天,不因 `last_synced_at` 更早而自动扩大范围;需要补历史缺口时
由操作员明确选择日期,单次仍不得超过 31 天。
@@ -393,19 +404,32 @@ POST /am/stock/detail/listByStock?hist=0
顺运宝给的是**商品级 ID + 规格原文**,没有規格 ID。
### 6.3 `productSpec` 的格式和蝦皮报表完全一致
### 6.3 `productSpec` 的角色顺序不是固定的
```text
蝦皮目录 `spec_raw` 黑色,M【建議40-50公斤】
顺运宝 productSpec 白色,L【建議50-60公斤】
黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】
均碼,黑色
```
`[必须]` 规格身份统一复用 `spec.SpecKey()`:只折叠空白,不猜颜色、尺码或建议。
第三方目录脚本提交 `spec_raw` 和明确的解析结果;顺运宝匹配不到时交给人工确认。
2026-09-04 的真实数据确认:`productSpec` 至少存在 `颜色,尺码` 与
`尺码,颜色` 两种顺序,不能再固定把逗号前当颜色、逗号后当尺码。
`[必须]` 先按最后一个 ASCII 逗号拆成两段并剥离 `【...】` 备注。只有两侧
恰好一侧带有明确尺码信号(如均码、字母尺码或明确尺码单位)时,才把该侧判为
尺码、另一侧判为颜色。两侧都像尺码时标记存疑;不得使用模糊颜色词库、AI 或
跨维度候选猜测角色。两侧都没有明确尺码信号时,暂按既有 `颜色,尺码` 契约兼容,
并继续执行原有复杂分隔符和空值校验。
`[必须]` 修正规格角色时保留原始 `raw_json`,不得覆盖人工确认值。已错误写入
虾皮档案的导入规格只能在确认无其他 SYB 明细引用且没有规格映射时移除;人工规格、
映射及历史采购任务保持不变。
`[必须]` 规格身份继续复用 `spec.SpecKey()`:只折叠空白,不改写用于 PDD
精确点击的原始候选标签。第三方目录脚本提交 `spec_raw` 和明确解析结果;角色或
规格比对不明确时保持存疑,不得跨颜色/尺码猜测。
### 6.4 由此推导出的匹配路径
```text
productId ──→ shopee_products.goods_id 商品级,直接相等
productSpec ──→ SpecKey() ──→ 稳定规格身份键
+53 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 80ae5c8cea87c624fff63ce3e302aa7bf204811e
synchronized_at: 2026-08-31T16:07:03Z
wiki_revision: f951dbc8b6a555cbf8f44cda073910406ba55191
synchronized_at: 2026-09-07T09:43:56Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -39,6 +39,57 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true
发布、重启、设置当前 Agent 版本和真机安装都属于高风险操作,必须有对应工单和人工确认。不得在部署过程中执行付款、创建订单或清理生产数据。
### API 长请求超时约束
Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允许 3~600 秒,复合的一键匹配共享 600 秒总预算,Admin 客户端等待 610 秒。因此服务端 `settings.application.writetimeout` 必须至少为 620 秒,受版本管理的标准配置固定为 `620`;启动时低于该值必须明确失败,禁止以 2 秒等短写超时运行,否则连接会在领域错误返回前被截断并表现为代理 502。反向代理的读/发送超时也必须大于服务端窗口。
Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。
## 回滚
服务异常时把 `/home/goauto/current` 切回上一已验证发布目录并重启 `goauto.service`,随后复核服务状态和 Agent 接口。不要删除当前或历史 APK/发布目录来代替回滚;数据库变化如需回退必须单独评估。
## 蝦皮规格自动匹配任务运维(#195)
- 迁移 `1788290000000_shopee_spec_auto_match.go` 创建运行与工作状态表,并幂等写入 `sys_job.invoke_target=GoAutoShopeeSpecAutoMatch`。首次部署状态为关闭,迁移重跑不会覆盖管理员后来修改的 Cron、参数或启停状态。
- 默认 Cron 为 `0 15 * * * *`(每小时第 15 分钟),默认 `batchLimit` 为 20,合法范围 1~100。上线后先保持关闭,在 Admin 蝦皮商品列表手动执行小批次并核对运行摘要和映射,再由管理员在定时任务页明确启用。
- 定时和手动触发共享数据库唯一活动槽。看到“已有自动匹配正在运行”属于正常互斥结果;运行租约超过 30 分钟会在下次触发时安全释放并把旧运行标记失败。处理每个商品前会续租。
- Provider 临时失败的商品至少 60 分钟后重试,最多 3 次;低置信度或无结果在输入指纹不变时不会重复调用。排错优先查看 `shopee_spec_auto_match_run` 的结构化计数与脱敏错误,以及 `shopee_spec_auto_match_work_item` 的状态、尝试次数、下次尝试时间和租约;不得输出或复制 API Key、Provider 原始响应、商品原始 JSON 或个人数据。
- 紧急回退只需关闭系统任务并停止使用 Admin 手动入口;保留运行记录和既有已确认映射用于审计,不删除数据,也不回滚 #194。
## SYB 异常规格 AI 定时任务(#198)
- 追加迁移 `1788354164329_syb_spec_ai_parse.go` 创建 AI 确认字段、`syb_spec_ai_parse_run`、`syb_spec_ai_parse_work_item`,并幂等写入 `sys_job.invoke_target=GoAutoSYBSpecAIParse`。
- 系统任务默认关闭,默认 Cron 为每小时第 5 分钟、参数 `{"batchLimit":20}`;迁移重跑不得覆盖管理员后续修改的 Cron、参数或启停状态。建议先运行该任务,再由默认第 15 分钟的 #195 完成蝦皮到 PDD 的规格匹配。
- 多实例通过可空唯一活动槽和租约保证全局单批运行;逐行工作项按输入指纹去重。Provider 临时失败至少 60 分钟后重试且最多 3 次,低置信度/无结果在输入不变时不重复调用。
- 上线顺序为:备份数据库、执行追加迁移、发布服务并保持任务关闭、核验表/字段/任务种子,再由管理员决定是否启用。排错只查看运行计数、工作状态和脱敏错误,不输出 API Key、Provider 原始响应、完整 RawJSON 或订单数据。
## 定时任务执行历史运维(#199)
- 发布 #199 时先备份数据库,再执行追加迁移 `1788357000000_sys_job_execution_log.go`,随后发布服务与 Web。迁移创建 `sys_job_execution_log`、登记 `GET /api/v1/sysjob/:id/execution-logs`,并只为原本绑定隐藏菜单 `JobLog` 的角色写入精确读取权限;菜单缺失时迁移明确失败,不扩大角色授权。
- 服务启动会将上次进程遗留的 `running` 执行记录标记为 `interrupted`。该恢复依赖当前线上每个数据库只运行一个调度器实例;扩展为多调度器前必须另建工单引入实例租约,不能直接复用此判断。
- 排错从 Admin 定时任务页单选任务后进入“日志”,按状态和开始时间查询。记录只含稳定错误码和脱敏摘要;需要定位细节时查看受控服务日志,不得把任务参数、Provider 配置/响应、密钥或业务原始数据复制进执行历史。
- 当前没有执行历史删除接口和自动保留策略;删除定时任务不删除历史。数据库容量治理需要另建工单评估。#198 的 `GoAutoSYBSpecAIParse` 在 #199 发布和迁移后仍保持关闭,启用必须由管理员另行确认。
## 客户端密钥部署与验证(#237)
实现基线 `71f7751`;2026-09-07 已按用户授权完成本机 MySQL 迁移、管理员菜单写入和 Server/Web 构建重启;管理页面 HTTP 列表与模块加载已验证。线上仍未部署。
- 发布前须单独授权追加迁移 `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。
- 客户端密钥功能不依赖 GOAUTO_TRUST_FORWARDED_PROTO、X-Forwarded-Proto 或 Agent HTTP 例外。既有其他路由的协议和代理配置保持不变;不伪造协议头,不新增明文放行开关。
- 代理、APM、应用日志均不得记录 Authorization、创建响应 secret 或原始业务载荷。应用对两类客户端密钥路由跳过旧请求/响应正文日志,使用专用元数据审计;实际代理日志脱敏仍须部署验收。
- 请求审计 status=0 可能表示在途、进程中断或结果审计更新失败;先按请求关联号核对业务结果,不自动重试采购、采集、同步等操作。停用阻止后续认证,不保证取消已开始的业务操作。
- 隔离验证:Server `go test ./app/goauto/clientkey ./app/goauto/clientapi ./cmd/migrate/migration/version-local`;Web `pnpm exec jest tests/unit/client-keys.spec.js --runInBand` 与 `pnpm run build:prod`。浏览器模拟入口 `/tests/fixtures/client-keys.html` 仅由本地 Vite 开发服务承载,使用内存模拟请求和无效示例密钥,不连接真实数据库,不证明线上鉴权已验收。
- 真实部署验收须另行验证实际 HTTP/HTTPS 入口、管理员创建/编辑/停用、普通用户拒绝、读写/独立动作隔离、停用后的后续请求拒绝及日志无密钥;任何真实业务执行继续按独立授权范围进行。
## Windows 本机运行目录与 #237 迁移验证(2026-09-07)
- Supervisor 实际配置 `D:/supervisor/programs/goauto.conf`;程序仅 `goauto-admin-api` 和 `goauto-admin-ui`。从已有干净工作区 `D:/OPC/goauto-worktrees/main-runtime` 的 main 分支运行;源码运行基线 `ac3c63e`,版本化启动模板更新提交 `9a10d82`。
- 原目录 `D:/OPC/goauto` 的用户改动保持原样;本机敏感配置继续读取 `D:/OPC/goauto/config.yaml`,不复制凭据到运行工作区或版本库。数据库 `127.0.0.1:3307/goauto`,API `http://127.0.0.1:8010`,Web `http://127.0.0.1:9527`。
- API 命令:`pwsh.exe -NoLogo -NoProfile -File "D:/OPC/goauto-worktrees/main-runtime/scripts/start-server.ps1" -ConfigPath "D:/OPC/goauto/config.yaml" -SkipMigration`;Web 同目录 `scripts/start-web.ps1` 与相同 ConfigPath。已验证无需 ExecutionPolicy Bypass。
- 真实迁移必须单独授权并先确认唯一待执行版本;常驻 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 未重启或发布。
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Home
wiki_revision: d5a46210da9dffa27247d80b20ab31b40790e48a
synchronized_at: 2026-08-31T16:06:14Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:29:34Z
<!-- gitea-wiki-mirror:end -->
# GoAuto 文档中心
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-Template
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-Template.-
wiki_revision: 389f09d292b0aa408f26f1967e33eec82d37cedf
synchronized_at: 2026-08-27T09:39:50Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:41Z
<!-- gitea-wiki-mirror:end -->
# 部署文档模板
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-Archive-Template
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Task-Archive-Template.-
wiki_revision: 53927b3b69fa26943f323f9be53153f80ed54530
synchronized_at: 2026-08-24T08:23:07Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:46Z
<!-- gitea-wiki-mirror:end -->
> 本模板只用于用户明确要求的专项历史快照或读取既有归档,不属于标准任务闭环。单次任务的唯一事实来源是 Gitea 工单;不要为了完成普通任务创建本页面,也不要自动导出到 `docs/task/`。
+4 -4
View File
@@ -1,6 +1,6 @@
[program:goauto-admin-api]
command=pwsh.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "D:/OPC/goauto/scripts/start-server.ps1" -ConfigPath "D:/OPC/goauto/config.yaml"
directory=D:/OPC/goauto
command=pwsh.exe -NoLogo -NoProfile -File "D:/OPC/goauto-worktrees/main-runtime/scripts/start-server.ps1" -ConfigPath "D:/OPC/goauto/config.yaml" -SkipMigration
directory=D:/OPC/goauto-worktrees/main-runtime
autostart=true
autorestart=true
startsecs=3
@@ -14,8 +14,8 @@ stdout_logfile_maxbytes=50MB
stdout_logfile_backups=5
[program:goauto-admin-ui]
command=pwsh.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "D:/OPC/goauto/scripts/start-web.ps1" -ConfigPath "D:/OPC/goauto/config.yaml"
directory=D:/OPC/goauto
command=pwsh.exe -NoLogo -NoProfile -File "D:/OPC/goauto-worktrees/main-runtime/scripts/start-web.ps1" -ConfigPath "D:/OPC/goauto/config.yaml"
directory=D:/OPC/goauto-worktrees/main-runtime
autostart=true
autorestart=true
startsecs=3
+2
View File
@@ -1,6 +1,7 @@
package router
import (
goautoclientapi "go-admin/app/goauto/clientapi"
"os"
"github.com/gin-gonic/gin"
@@ -53,6 +54,7 @@ func InitRouter() {
// 注册 GoAuto Agent 与设备管理路由。
goautodevice.InitRouter(r, authMiddleware)
goautoclientapi.InitRouter(r, authMiddleware)
goautoapprelease.InitRouter(r, authMiddleware)
goautoaimatching.InitRouter(r, authMiddleware)
goautotask.InitRouter(r, authMiddleware)
+2
View File
@@ -136,6 +136,8 @@ func moduleKeyForAPI(path string) string {
return ModulePDDProducts
case strings.HasPrefix(path, "/api/admin/v1/shopee-products"):
return ModuleShopeeProducts
case strings.HasPrefix(path, "/api/admin/v1/shopee-spec-auto-match"):
return ModuleShopeeProducts
case strings.HasPrefix(path, "/api/admin/v1/syb-products/sync-runs"):
return ModuleSYBSyncRuns
case strings.HasPrefix(path, "/api/admin/v1/syb-products"):
+3 -1
View File
@@ -48,13 +48,15 @@ var AdminAPIs = []APIPermission{
{"AI 建议颜色映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-colors", "POST", true},
{"AI 建议尺码映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-sizes", "POST", true},
{"一键匹配并确认颜色尺码", "/api/admin/v1/shopee-products/:productId/specs/mapping/auto-match", "POST", true},
{"手动执行虾皮规格自动匹配", "/api/admin/v1/shopee-spec-auto-match/runs", "POST", false},
{"查看最近虾皮规格自动匹配", "/api/admin/v1/shopee-spec-auto-match/runs/latest", "GET", false},
{"查看 SYB 商品", "/api/admin/v1/syb-products", "GET", true},
{"查看 SYB 商品详情", "/api/admin/v1/syb-products/:productId", "GET", true},
{"重新解析 SYB 商品", "/api/admin/v1/syb-products/:productId/reparse", "POST", true},
{"批量重新解析 SYB 商品", "/api/admin/v1/syb-products/reparse-batch", "POST", true},
{"人工修正 SYB 商品", "/api/admin/v1/syb-products/:productId/correction", "PATCH", true},
{"手动同步 SYB 商品", "/api/admin/v1/syb-products/import", "POST", false},
{"手动同步 SYB 商品", "/api/admin/v1/syb-products/import", "POST", true},
{"查看 SYB 同步记录", "/api/admin/v1/syb-products/sync-runs", "GET", true},
{"查看 SYB 同步详情", "/api/admin/v1/syb-products/sync-runs/:runId", "GET", true},
+6 -4
View File
@@ -15,10 +15,12 @@ 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-products/import": true,
"POST /api/admin/v1/collection-rules": true,
"PUT /api/admin/v1/ai-matching-settings": true,
"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,
}
for _, permission := range PurchaserAPIs() {
if denied[permission.Method+" "+permission.Path] {
+3 -1
View File
@@ -55,7 +55,9 @@ func TestReconcilePurchaserPermissions(t *testing.T) {
}
assertPolicyCount(t, db, "custom-role", "/custom", "GET", 1)
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-products/import", "POST", 0)
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-products/import", "POST", 1)
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-shops/discover", "POST", 0)
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-shops", "POST", 0)
}
func TestReconcilePurchaserPermissionsRemovesOnlyStalePurchaserGrant(t *testing.T) {
+96 -2
View File
@@ -25,12 +25,28 @@ const (
defaultAutoConfirmMinConfidence = 0.9
)
// MaxProviderTimeout is also the total budget used by composite synchronous
// AI operations. This keeps their HTTP response inside the Admin and API
// transport windows even when an operation needs more than one provider call.
const MaxProviderTimeout = 600 * time.Second
// Service owns the internal AI Provider configuration. The API key exception
// is deliberately narrow: it is plain text only in the dedicated settings
// table and is returned only by the administrator settings handler.
type Service struct {
DB *gorm.DB
HTTPClient *http.Client
DB *gorm.DB
HTTPClient *http.Client
ProviderFailureLogger func(ProviderFailureDiagnostic)
}
// ProviderFailureDiagnostic deliberately contains no URL, model, prompt,
// candidates, response body or credential. It is safe for operational logs.
type ProviderFailureDiagnostic struct {
CallID string
Operation string
Kind string
StatusCode int
Duration time.Duration
}
func NewService(db *gorm.DB) *Service {
@@ -179,6 +195,58 @@ func (s *Service) Resolve(ctx context.Context, request MatchRequest) (MatchResul
return result, nil
}
// ResolveSYBSpec parses one SYB productSpec into the linked Shopee product's
// exact color/size labels. Unlike Resolve, this operation does not map to PDD:
// every non-empty answer must be an exact member of the supplied Shopee set.
func (s *Service) ResolveSYBSpec(ctx context.Context, request SYBSpecParseRequest) (SYBSpecParseResult, error) {
request.ProductSpec = strings.TrimSpace(request.ProductSpec)
request.Colors = usableCandidates(request.Colors)
request.Sizes = usableCandidates(request.Sizes)
if request.ProductSpec == "" || (len(request.Colors) == 0 && len(request.Sizes) == 0) {
return SYBSpecParseResult{}, fail(CodeNoMatch, "SYB 采购规格缺少可判断的原文或蝦皮候选")
}
setting, apiKey, err := s.activeSetting(ctx)
if err != nil {
return SYBSpecParseResult{}, err
}
payload := openAIChatRequest{Model: setting.Model, Temperature: 0, Messages: []openAIMessage{
{Role: "system", Content: "你只负责把一条 SYB 商品规格原文解析成给定蝦皮候选中的原始颜色和尺码。不得猜测、不得改写候选、不得返回候选外文本。只返回 JSON:{\"color\":\"颜色候选原文或空\",\"size\":\"尺码候选原文或空\",\"reason\":\"简短原因\",\"confidence\":0到1}。提供了某角色候选时必须唯一可靠地选择一个,否则对应字段留空。"},
{Role: "user", Content: sybSpecParsePrompt(request)},
}}
body, err := json.Marshal(payload)
if err != nil {
return SYBSpecParseResult{}, &Error{Code: CodeProviderUnavailable, Message: "SYB 规格 AI 解析请求生成失败", Cause: err}
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(setting.TimeoutSeconds)*time.Second)
defer cancel()
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint(setting.BaseURL, "chat/completions"), bytes.NewReader(body))
if err != nil {
return SYBSpecParseResult{}, fail(CodeInvalidSetting, "AI 服务地址无效")
}
httpRequest.Header.Set("Authorization", "Bearer "+apiKey)
httpRequest.Header.Set("Content-Type", "application/json")
response, err := s.httpClient().Do(httpRequest)
if err != nil {
return SYBSpecParseResult{}, &Error{Code: CodeProviderUnavailable, Message: "SYB 规格 AI 解析服务暂时不可用", Cause: err}
}
defer response.Body.Close()
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return SYBSpecParseResult{}, fail(CodeProviderUnavailable, "SYB 规格 AI 解析服务暂时不可用")
}
choice, err := parseProviderChoice(responseBody)
if err != nil || !validClosedChoice(choice.Color, request.Colors) || !validClosedChoice(choice.Size, request.Sizes) {
return SYBSpecParseResult{}, fail(CodeNoMatch, "AI 未能在蝦皮候选中唯一解析采购规格")
}
if choice.Confidence == nil || *choice.Confidence < 0 || *choice.Confidence > 1 || strings.TrimSpace(choice.Reason) == "" {
return SYBSpecParseResult{}, fail(CodeNoMatch, "AI 解析结果缺少有效置信度或理由")
}
return SYBSpecParseResult{
Color: choice.Color, Size: choice.Size, Provider: ProviderOpenAICompatible,
Model: setting.Model, Reason: safeReason(choice.Reason), Confidence: choice.Confidence,
}, nil
}
func (s *Service) activeSetting(ctx context.Context) (models.AIMatchingSetting, string, error) {
setting, err := s.setting(ctx)
if errors.Is(err, gorm.ErrRecordNotFound) || !setting.Enabled {
@@ -294,6 +362,22 @@ func validChoice(target, selected string, candidates []string) bool {
return false
}
func validClosedChoice(selected string, candidates []string) bool {
selected = strings.TrimSpace(selected)
if len(candidates) == 0 {
return selected == ""
}
if selected == "" {
return false
}
for _, candidate := range candidates {
if candidate == selected {
return true
}
}
return false
}
func safeReason(reason string) string {
reason = strings.TrimSpace(reason)
if reason == "" {
@@ -316,6 +400,16 @@ func matchPrompt(request MatchRequest) string {
return string(raw)
}
func sybSpecParsePrompt(request SYBSpecParseRequest) string {
payload := struct {
ProductSpec string `json:"productSpec"`
Colors []string `json:"shopeeColorCandidates,omitempty"`
Sizes []string `json:"shopeeSizeCandidates,omitempty"`
}{request.ProductSpec, request.Colors, request.Sizes}
raw, _ := json.Marshal(payload)
return string(raw)
}
type openAIMessage struct {
Role string `json:"role"`
Content string `json:"content"`
@@ -2,6 +2,7 @@ package aimatching
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
@@ -135,3 +136,61 @@ func TestAutoConfirmThresholdDefaultsPersistsAndValidates(t *testing.T) {
t.Fatalf("invalid threshold err=%v", err)
}
}
func TestResolveSYBSpecUsesOnlyProductSpecAndClosedShopeeCandidates(t *testing.T) {
service := matcherTestService(t)
if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7); err != nil {
t.Fatal(err)
}
var sent map[string]any
service.HTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
raw, err := io.ReadAll(request.Body)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &sent); err != nil {
t.Fatal(err)
}
body := `{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"原文对应唯一候选\",\"confidence\":0.95}"}}]}`
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil
})}
result, err := service.ResolveSYBSpec(context.Background(), SYBSpecParseRequest{ProductSpec: "黑色 XL【备注】", Colors: []string{"黑色", "白色"}, Sizes: []string{"L", "XL"}})
if err != nil || result.Color != "黑色" || result.Size != "XL" || result.Confidence == nil || *result.Confidence != 0.95 {
t.Fatalf("result=%+v err=%v", result, err)
}
encoded, _ := json.Marshal(sent)
for _, forbidden := range []string{"orderCode", "address", "rawJson", "price", "test-secret"} {
if strings.Contains(string(encoded), forbidden) {
t.Fatalf("provider payload leaked forbidden field %q: %s", forbidden, encoded)
}
}
for _, required := range []string{"productSpec", "shopeeColorCandidates", "shopeeSizeCandidates"} {
if !strings.Contains(string(encoded), required) {
t.Fatalf("provider payload missing %q: %s", required, encoded)
}
}
}
func TestResolveSYBSpecRejectsCandidateOutsideClosedSetAndMissingConfidence(t *testing.T) {
service := matcherTestService(t)
if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7); err != nil {
t.Fatal(err)
}
responses := []string{
`{"choices":[{"message":{"content":"{\"color\":\"灰色\",\"size\":\"XL\",\"reason\":\"猜测\",\"confidence\":0.99}"}}]}`,
`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"候选\"}"}}]}`,
}
service.HTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
body := responses[0]
responses = responses[1:]
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil
})}
request := SYBSpecParseRequest{ProductSpec: "黑 XL", Colors: []string{"黑色"}, Sizes: []string{"XL"}}
for i := 0; i < 2; i++ {
_, err := service.ResolveSYBSpec(context.Background(), request)
var target *Error
if !errors.As(err, &target) || target.Code != CodeNoMatch {
t.Fatalf("attempt %d err=%v", i, err)
}
}
}
+28 -1
View File
@@ -10,6 +10,9 @@ import (
"net/http"
"strings"
"time"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/google/uuid"
)
// Suggestion limits are enforced defensively here too, even though callers
@@ -95,18 +98,26 @@ func (s *Service) SuggestBatch(ctx context.Context, request SuggestRequest) (Sug
}
httpRequest.Header.Set("Authorization", "Bearer "+apiKey)
httpRequest.Header.Set("Content-Type", "application/json")
callID, startedAt := uuid.NewString(), time.Now()
response, err := s.httpClient().Do(httpRequest)
if err != nil {
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: providerNetworkErrorKind(err), Duration: time.Since(startedAt)})
return SuggestResult{}, &Error{Code: CodeProviderUnavailable, Message: "AI 建议服务暂时不可用", Cause: err}
}
defer response.Body.Close()
limited := io.LimitReader(response.Body, 1<<20)
responseBody, readErr := io.ReadAll(limited)
if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
if readErr != nil {
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "read_error", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议服务暂时不可用")
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "http_status", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议服务暂时不可用")
}
raw, err := parseSuggestChoices(responseBody)
if err != nil {
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "invalid_response", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议响应无效")
}
@@ -145,6 +156,22 @@ func (s *Service) SuggestBatch(ctx context.Context, request SuggestRequest) (Sug
return SuggestResult{Decisions: decisions, Provider: ProviderOpenAICompatible, Model: setting.Model}, nil
}
func (s *Service) logProviderFailure(diagnostic ProviderFailureDiagnostic) {
if s.ProviderFailureLogger != nil {
s.ProviderFailureLogger(diagnostic)
return
}
log.Warnf("AI provider call failed: call_id=%s operation=%s kind=%s status=%d duration_ms=%d",
diagnostic.CallID, diagnostic.Operation, diagnostic.Kind, diagnostic.StatusCode, diagnostic.Duration.Milliseconds())
}
func providerNetworkErrorKind(err error) string {
if errors.Is(err, context.DeadlineExceeded) {
return "timeout"
}
return "network_error"
}
func suggestSystemPrompt(dimension string) string {
noun := "颜色或尺码"
switch dimension {
@@ -6,7 +6,9 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-admin/app/goauto/models"
@@ -134,3 +136,93 @@ func TestSuggestBatchRequiresConfiguredProvider(t *testing.T) {
t.Fatalf("expected CodeNotConfigured, got %v", target.Code)
}
}
func TestSuggestBatchLogsSafeDiagnosticForProvider502(t *testing.T) {
const sensitiveBody = "api-key-and-provider-body-must-not-be-logged"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, sensitiveBody, http.StatusBadGateway)
}))
defer server.Close()
db := openSuggestTestDB(t)
seedEnabledSetting(t, db, server.URL)
var diagnostic ProviderFailureDiagnostic
service := NewService(db)
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
Sources: []SuggestSource{{ID: "s1", Label: "sensitive-source"}},
Candidates: []SuggestCandidate{{ID: "c1", Label: "sensitive-candidate"}},
})
if err == nil {
t.Fatal("expected provider failure")
}
if diagnostic.Operation != "suggest_batch" || diagnostic.Kind != "http_status" || diagnostic.StatusCode != http.StatusBadGateway || diagnostic.CallID == "" {
t.Fatalf("unexpected diagnostic: %+v", diagnostic)
}
printed := fmt.Sprintf("%+v", diagnostic)
for _, secret := range []string{sensitiveBody, "sensitive-source", "sensitive-candidate", "test-key", server.URL} {
if strings.Contains(printed, secret) {
t.Fatalf("diagnostic leaked %q: %s", secret, printed)
}
}
}
func TestSuggestBatchClassifiesProviderTimeout(t *testing.T) {
db := openSuggestTestDB(t)
seedEnabledSetting(t, db, "http://provider.invalid")
var diagnostic ProviderFailureDiagnostic
service := NewService(db)
service.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, context.DeadlineExceeded
})}
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
})
if err == nil || diagnostic.Kind != "timeout" || diagnostic.StatusCode != 0 {
t.Fatalf("timeout was not safely classified: diagnostic=%+v err=%v", diagnostic, err)
}
}
func TestSuggestBatchClassifiesInvalidResponseWithoutLoggingBody(t *testing.T) {
const sensitiveBody = "not-json-with-sensitive-provider-details"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(sensitiveBody))
}))
defer server.Close()
db := openSuggestTestDB(t)
seedEnabledSetting(t, db, server.URL)
var diagnostic ProviderFailureDiagnostic
service := NewService(db)
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
})
if err == nil || diagnostic.Kind != "invalid_response" || strings.Contains(fmt.Sprintf("%+v", diagnostic), sensitiveBody) {
t.Fatalf("invalid response diagnostic is unsafe or missing: diagnostic=%+v err=%v", diagnostic, err)
}
}
func TestSuggestBatchAllowsProviderResponseAfterTwoSeconds(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2100 * time.Millisecond)
chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.95,"reason":"match"}]}`)(w, r)
}))
defer server.Close()
db := openSuggestTestDB(t)
seedEnabledSetting(t, db, server.URL)
result, err := NewService(db).SuggestBatch(context.Background(), SuggestRequest{
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
})
if err != nil || result.Decisions["s1"].CandidateID != "c1" {
t.Fatalf("delayed provider response failed: result=%+v err=%v", result, err)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return fn(request) }
+24 -5
View File
@@ -29,17 +29,36 @@ type MatchRequest struct {
Sizes []string
}
// SYBSpecParseRequest contains the only source text and closed Shopee
// candidate sets that may leave GoAuto for an AI-assisted SYB parse. It must
// never contain the shipment/order, account, address, price or full raw JSON.
type SYBSpecParseRequest struct {
ProductSpec string
Colors []string
Sizes []string
}
type SYBSpecParseResult struct {
Color string
Size string
Provider string
Model string
Reason string
Confidence *float64
}
type CandidateSnapshot struct {
Colors []string `json:"colors,omitempty"`
Sizes []string `json:"sizes,omitempty"`
}
type DecisionSnapshot struct {
Source string `json:"source"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Candidates CandidateSnapshot `json:"candidates"`
Matched struct {
Source string `json:"source"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Candidates CandidateSnapshot `json:"candidates"`
RoleSources map[string]string `json:"roleSources,omitempty"`
Matched struct {
Color string `json:"color,omitempty"`
Size string `json:"size,omitempty"`
} `json:"matched"`
+1 -1
View File
@@ -18,7 +18,7 @@ func TestParseBuiltAgentAPKWhenAvailable(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if metadata.VersionCode != 49 || metadata.VersionName != "0.9.36" {
if metadata.VersionCode != 54 || metadata.VersionName != "0.9.41" {
t.Fatalf("metadata=%+v", metadata)
}
}
+158
View File
@@ -0,0 +1,158 @@
package clientapi
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"go-admin/app/goauto/clientkey"
"go-admin/common/clientprincipal"
"gorm.io/gorm"
)
// Both HTTP and HTTPS are supported by explicit operator decision (#237).
// Transport does not grant identity or permissions; secrets remain non-cacheable.
func NoStore(c *gin.Context) {
c.Header("Cache-Control", "no-store")
c.Next()
}
func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Cache-Control", "no-store")
deny := func(status int, message string) {
c.AbortWithStatusJSON(status, gin.H{"code": status, "message": message})
}
// Never accept a credential supplied through a URL or cookie fallback.
for k := range c.Request.URL.Query() {
if sensitive(k) || strings.EqualFold(k, "token") {
deny(400, "密钥只能通过 Authorization 请求头发送")
return
}
}
header := strings.Fields(c.GetHeader("Authorization"))
if len(header) != 2 || !strings.EqualFold(header[0], "Bearer") {
deny(401, "需要客户端密钥")
return
}
connection := db
var err error
if connection == nil {
connection, err = pkg.GetOrm(c)
}
if err != nil || connection == nil {
deny(503, "客户端认证暂不可用")
return
}
key, err := (clientkey.Service{DB: connection}).Authenticate(c.Request.Context(), header[1])
if err != nil {
if errors.Is(err, clientkey.ErrCredential) {
deny(401, "客户端密钥无效或已停用")
} else {
deny(503, "客户端认证暂不可用")
}
return
}
random := make([]byte, 16)
if _, err = rand.Read(random); err != nil {
deny(503, "审计暂不可用")
return
}
requestID := hex.EncodeToString(random)
c.Header("X-Client-Request-Id", requestID)
record := clientkey.Audit{KeyID: key.ID, Event: "request", RequestID: requestID, Method: e.Method, Route: "/api/client/v1" + e.Path}
if !key.Allows(e.Module, e.Capability) {
record.Status = 403
_ = connection.Create(&record).Error
deny(403, "密钥未授权此模块或执行动作")
return
}
// The intent must be durable before any business handler can run.
if err = connection.WithContext(c.Request.Context()).Create(&record).Error; err != nil {
deny(503, "审计暂不可用,未执行操作")
return
}
clientprincipal.Set(c, clientprincipal.Identity{KeyID: key.ID, RequestID: requestID, AuthorizedBy: key.UpdatedBy})
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20)
original := c.Writer
buffer := &responseBuffer{ResponseWriter: original, status: 200}
c.Writer = buffer
defer func() { c.Writer = original }()
c.Next()
c.Writer = original
status := buffer.status
var value any
if buffer.overflow || json.Unmarshal(buffer.body.Bytes(), &value) != nil {
status = 502
value = gin.H{"code": 502, "message": "无法生成客户端响应,请先核对操作结果,不要自动重试"}
} else {
value = redact(value)
}
auditCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = connection.WithContext(auditCtx).Model(&clientkey.Audit{}).Where("id = ?", record.ID).Update("status", status).Error
now := time.Now().UTC()
_ = connection.WithContext(auditCtx).Model(&clientkey.Key{}).Where("id = ?", key.ID).UpdateColumn("last_used_at", now).Error
c.JSON(status, value)
}
}
type responseBuffer struct {
gin.ResponseWriter
body bytes.Buffer
status int
overflow bool
}
func (w *responseBuffer) WriteHeader(status int) { w.status = status }
func (w *responseBuffer) WriteHeaderNow() {}
func (w *responseBuffer) Status() int { return w.status }
func (w *responseBuffer) Size() int { return w.body.Len() }
func (w *responseBuffer) Written() bool { return w.body.Len() > 0 }
func (w *responseBuffer) Write(b []byte) (int, error) {
if w.body.Len()+len(b) > 32<<20 {
w.overflow = true
return len(b), nil
}
return w.body.Write(b)
}
func (w *responseBuffer) WriteString(s string) (int, error) { return w.Write([]byte(s)) }
func (w *responseBuffer) Flush() {}
func sensitive(k string) bool {
key := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(k, "_", ""), "-", ""))
for _, part := range []string{"password", "passwd", "secret", "credential", "cookie", "authorization", "apikey", "accesstoken", "devicetoken", "refreshtoken", "recoverycode"} {
if strings.Contains(key, part) {
return true
}
}
switch key {
case "token", "tokenhash", "digest", "rawjson", "rawpayload", "rawresponse", "requestheaders", "responseheaders":
return true
}
return false
}
func redact(v any) any {
switch value := v.(type) {
case map[string]any:
for k, item := range value {
if sensitive(k) {
delete(value, k)
} else {
value[k] = redact(item)
}
}
case []any:
for i, item := range value {
value[i] = redact(item)
}
}
return v
}
+165
View File
@@ -0,0 +1,165 @@
package clientapi
import (
"context"
"crypto/tls"
"encoding/json"
"github.com/gin-gonic/gin"
"go-admin/app/goauto/clientkey"
"go-admin/common/clientprincipal"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"net/http/httptest"
"strings"
"testing"
)
func fixture(t *testing.T) (*gorm.DB, clientkey.Service) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
sql, _ := db.DB()
sql.SetMaxOpenConns(1)
t.Cleanup(func() { sql.Close() })
if err = db.AutoMigrate(&clientkey.Key{}, &clientkey.Audit{}); err != nil {
t.Fatal(err)
}
return db, clientkey.Service{DB: db, Modules: Catalog(Inventory())}
}
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
db, s := fixture(t)
routes := Inventory()
if len(s.Modules) != 12 {
t.Fatal("menu groups lost")
}
router := gin.New()
seen := map[string]bool{}
for _, e := range routes {
key := e.Method + e.Path
if seen[key] {
t.Fatal("duplicate route")
}
seen[key] = true
if strings.Contains(e.Path, "payment") || strings.Contains(e.Path, "token") || strings.Contains(e.Path, "client-keys") || e.Handle == nil {
t.Fatal("forbidden route")
}
router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), func(c *gin.Context) { c.JSON(200, gin.H{"data": "ok"}) })
if e.Method != "GET" && e.Capability == "read" {
t.Fatal("mutating read permission")
}
}
for _, e := range routes {
for _, scheme := range []string{"http", "https"} {
grant := clientkey.Grant{Module: e.Module}
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{grant}, 1)
if err != nil {
t.Fatal(err)
}
path := e.Path
for _, param := range []string{":productId", ":runId", ":recordId", ":jobId", ":batchId", ":shopId", ":ruleId", ":taskId"} {
path = strings.ReplaceAll(path, param, "1")
}
req := httptest.NewRequest(e.Method, scheme+"://example.test/api/client/v1"+path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
want := 200
if e.Capability != "read" {
want = 403
}
if rr.Code != want {
t.Fatalf("%s got %d want %d", keyFor(e), rr.Code, want)
}
if e.Capability == "write" {
grant.Write = true
} else if e.Capability != "read" {
grant.Actions = []string{e.Capability}
}
if _, err = s.Update(context.Background(), v.ID, 1, 1, []clientkey.Grant{grant}, false); err != nil {
t.Fatal(err)
}
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req.Clone(context.Background()))
if rr.Code != 200 {
t.Fatalf("authorized %s failed: %d", keyFor(e), rr.Code)
}
}
}
}
func keyFor(e Endpoint) string { return e.Method + " " + e.Path }
func TestRevocationTransportRedactionAndAudit(t *testing.T) {
db, s := fixture(t)
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products"}}, 1)
if err != nil {
t.Fatal(err)
}
e := Endpoint{Method: "GET", Path: "/pdd-products", Module: "pdd_products", Capability: "read"}
router := gin.New()
calls := 0
router.GET("/api/client/v1/pdd-products", Gate(db, e), func(c *gin.Context) {
calls++
id, ok := clientprincipal.Get(c)
if !ok || id.KeyID != v.ID {
t.Error("client attribution missing")
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"apiKey": "sentinel-secret", "rawJson": "sentinel-raw", "title": "product", "nested": []any{gin.H{"device_token": "sentinel-token"}}}})
})
send := func(tlsOn bool, credential, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "http://example.test"+path, nil)
if tlsOn {
req.TLS = &tls.ConnectionState{}
}
req.Header.Set("Authorization", "Bearer "+credential)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
path := "/api/client/v1/pdd-products"
for _, tlsOn := range []bool{false, true} {
if send(tlsOn, "jwt", path).Code != 401 || send(tlsOn, "", path).Code != 401 || send(tlsOn, token, path+"?token=invalid").Code != 400 {
t.Fatal("credential fallback accepted")
}
rr := send(tlsOn, token, path)
if rr.Code != 200 || strings.Contains(rr.Body.String(), "sentinel") || !strings.Contains(rr.Body.String(), "product") {
t.Fatal("redaction failed")
}
if rr.Header().Get("Cache-Control") != "no-store" || rr.Header().Get("X-Client-Request-Id") == "" {
t.Fatal("cache or audit headers missing")
}
}
if _, err = s.Update(context.Background(), v.ID, 1, 1, nil, true); err != nil {
t.Fatal(err)
}
if send(true, token, path).Code != 401 || send(false, token, path).Code != 401 || calls != 2 {
t.Fatal("revocation not immediate")
}
var logs []clientkey.Audit
db.Find(&logs)
raw, _ := json.Marshal(logs)
if strings.Contains(string(raw), token) || strings.Contains(string(raw), "sentinel") {
t.Fatal("audit leaked payload")
}
}
func TestAuditUnavailableDoesNotExecute(t *testing.T) {
db, s := fixture(t)
_, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products", Write: true}}, 1)
if err != nil {
t.Fatal(err)
}
db.Migrator().DropTable(&clientkey.Audit{})
router := gin.New()
called := false
e := Endpoint{Method: "POST", Path: "/pdd-products", Module: "pdd_products", Capability: "write"}
router.POST(e.Path, Gate(db, e), func(c *gin.Context) { called = true; c.JSON(200, gin.H{}) })
req := httptest.NewRequest("POST", "https://example.test"+e.Path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != 503 || called {
t.Fatal("executed without audit")
}
}
@@ -0,0 +1,81 @@
package clientapi
import (
"context"
"encoding/json"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/goauto/clientkey"
"go-admin/app/goauto/models"
"go-admin/app/goauto/product"
"go-admin/common/middleware"
"net/http/httptest"
"strings"
"testing"
)
func TestClientCanCreateProductWithoutLoginAndReplay(t *testing.T) {
db, s := fixture(t)
if err := db.AutoMigrate(&models.PDDProduct{}); err != nil {
t.Fatal(err)
}
_, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products", Write: true}}, 1)
if err != nil {
t.Fatal(err)
}
e := Endpoint{Method: "POST", Path: "/pdd-products", Module: "pdd_products", Capability: "write"}
router := gin.New()
router.Use(func(c *gin.Context) { c.Set("db", db); c.Next() })
router.POST("/api/client/v1/pdd-products", Gate(db, e), product.Handler{}.Create)
for n := 0; n < 2; n++ {
req := httptest.NewRequest("POST", "https://example.test/api/client/v1/pdd-products", strings.NewReader(`{"requestId":"00000000-0000-4000-8000-000000000237","url":"https://mobile.yangkeduo.com/goods.html?goods_id=100000000001"}`))
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("product create failed status %d", rr.Code)
}
var body struct {
Data struct {
Replayed bool `json:"replayed"`
}
}
json.Unmarshal(rr.Body.Bytes(), &body)
if (n == 1) != body.Data.Replayed {
t.Fatal("business idempotency changed")
}
}
var count int64
db.Model(&models.PDDProduct{}).Count(&count)
if count != 1 {
t.Fatal("duplicate business write")
}
}
func TestManagementRequiresAdminNotClientIdentity(t *testing.T) {
db, s := fixture(t)
h := clientkey.Handler{DB: db, Modules: s.Modules}
for _, role := range []string{"admin", "purchaser", "client", ""} {
for _, scheme := range []string{"http", "https"} {
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role, "identity": float64(7)})
c.Next()
})
router.POST("/keys", middleware.RequireRoleKey("admin"), NoStore, h.Create)
req := httptest.NewRequest("POST", scheme+"://example.test/keys", strings.NewReader(`{"name":"test","grants":[{"module":"pdd_products","write":false,"actions":[]}]}`))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
want := 403
if role == "admin" {
want = 200
}
if rr.Code != want {
t.Fatalf("role %q status %d", role, rr.Code)
}
if role == "admin" && rr.Header().Get("Cache-Control") != "no-store" {
t.Fatal("one-time secret cacheable")
}
}
}
}
+45
View File
@@ -0,0 +1,45 @@
package clientapi
import (
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"go-admin/app/goauto/aimatching"
"io"
"net/http"
)
// resolveSpecs accepts only specification text, never a provider URL/key,
// prompt, raw order, address or account. It returns a suggestion, not an order.
func resolveSpecs(c *gin.Context) {
var req struct {
TargetColor string `json:"targetColor"`
TargetSize string `json:"targetSize"`
Colors []string `json:"colors"`
Sizes []string `json:"sizes"`
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
d := json.NewDecoder(c.Request.Body)
d.DisallowUnknownFields()
if d.Decode(&req) != nil || d.Decode(&struct{}{}) != io.EOF || len(req.Colors) > 200 || len(req.Sizes) > 200 {
c.JSON(422, gin.H{"code": 422, "message": "规格请求无效"})
return
}
for _, s := range append(append([]string{req.TargetColor, req.TargetSize}, req.Colors...), req.Sizes...) {
if len([]rune(s)) > 255 {
c.JSON(422, gin.H{"code": 422, "message": "规格值过长"})
return
}
}
db, err := pkg.GetOrm(c)
if err != nil {
c.JSON(503, gin.H{"code": 503})
return
}
v, err := aimatching.NewService(db).Resolve(c.Request.Context(), aimatching.MatchRequest{TargetColor: req.TargetColor, TargetSize: req.TargetSize, Colors: req.Colors, Sizes: req.Sizes})
if err != nil {
c.JSON(422, gin.H{"code": 422, "message": "未获得可靠匹配,请检查候选规格或联系管理员"})
return
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"mappedColor": v.MappedColor, "mappedSize": v.MappedSize, "source": v.Source}})
}
+170
View File
@@ -0,0 +1,170 @@
// Package clientapi exposes only the reviewed client route inventory. It never
// forwards arbitrary URLs or synthesizes a logged-in administrator identity.
package clientapi
import (
"go-admin/app/goauto/access"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/clientkey"
"go-admin/app/goauto/device"
"go-admin/app/goauto/product"
"go-admin/app/goauto/purchase"
"go-admin/app/goauto/purchaserule"
"go-admin/app/goauto/rule"
"go-admin/app/goauto/shopeeproduct"
"go-admin/app/goauto/sybimport"
"go-admin/app/goauto/sybinnercode"
"go-admin/app/goauto/sybshop"
"go-admin/app/goauto/task"
"go-admin/common/middleware"
"sort"
"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"
)
type Endpoint struct {
Method, Path, Module, Capability string
Handle gin.HandlerFunc
}
func Inventory() []Endpoint {
p := product.Handler{}
s := shopeeproduct.Handler{}
y := sybimport.Handler{}
i := sybinnercode.Handler{}
shop := sybshop.Handler{}
r := rule.Handler{}
pr := purchaserule.Handler{}
t := task.Handler{}
buy := purchase.Handler{}
return []Endpoint{
{"POST", "/ai-matching-settings/resolve", access.ModuleAIMatching, "match", resolveSpecs},
{"GET", "/devices", access.ModuleDevices, "read", device.Handler{}.List},
{"GET", "/pdd-products", access.ModulePDDProducts, "read", p.List},
{"GET", "/pdd-products/:productId", access.ModulePDDProducts, "read", p.Detail},
{"POST", "/pdd-products", access.ModulePDDProducts, "write", p.Create},
{"PATCH", "/pdd-products/:productId", access.ModulePDDProducts, "write", p.Update},
{"GET", "/shopee-products", access.ModuleShopeeProducts, "read", s.List},
{"GET", "/shopee-products/:productId", access.ModuleShopeeProducts, "read", s.Detail},
{"POST", "/shopee-products", access.ModuleShopeeProducts, "write", s.Create},
{"PATCH", "/shopee-products/:productId", access.ModuleShopeeProducts, "write", s.Update},
{"POST", "/shopee-products/:productId/link-pdd", access.ModuleShopeeProducts, "write", s.LinkPDD},
{"POST", "/shopee-products/:productId/specs/values", access.ModuleShopeeProducts, "write", s.AddSpecValue},
{"PUT", "/shopee-products/:productId/specs/mapping", access.ModuleShopeeProducts, "write", s.SetMapping},
{"DELETE", "/shopee-products/:productId/specs/values", access.ModuleShopeeProducts, "delete", s.RemoveSpecValue},
{"DELETE", "/shopee-products/:productId/specs/mapping", access.ModuleShopeeProducts, "delete", s.ClearMapping},
{"POST", "/shopee-products/batch-delete", access.ModuleShopeeProducts, "delete", s.BatchDelete},
{"POST", "/shopee-products/:productId/specs/mapping/auto-match", access.ModuleShopeeProducts, "match", s.AutoMatchMappings},
{"POST", "/shopee-products/:productId/specs/mapping/confirm", access.ModuleShopeeProducts, "match", s.ConfirmMapping},
{"GET", "/syb-products", access.ModuleSYBProducts, "read", y.List},
{"GET", "/syb-products/:productId", access.ModuleSYBProducts, "read", y.Detail},
{"PATCH", "/syb-products/:productId/correction", access.ModuleSYBProducts, "write", y.ManualCorrect},
{"POST", "/syb-products/:productId/reparse", access.ModuleSYBProducts, "reparse", y.Reparse},
{"POST", "/syb-products/reparse-batch", access.ModuleSYBProducts, "reparse", y.ReparseBatch},
{"GET", "/syb-products/sync-runs", access.ModuleSYBSyncRuns, "read", y.ListSyncRuns},
{"GET", "/syb-products/sync-runs/:runId", access.ModuleSYBSyncRuns, "read", y.SyncRunDetail},
{"POST", "/syb-products/import", access.ModuleSYBSyncRuns, "sync", y.Import},
{"GET", "/syb-inner-codes", access.ModuleSYBInnerCodes, "read", i.List},
{"GET", "/syb-inner-codes/:recordId", access.ModuleSYBInnerCodes, "read", i.Detail},
{"GET", "/syb-inner-codes/match-jobs/:jobId", access.ModuleSYBInnerCodes, "read", i.MatchJob},
{"GET", "/syb-inner-codes/apply-batches/:batchId", access.ModuleSYBInnerCodes, "read", i.ApplyBatch},
{"POST", "/syb-inner-codes/rematch", access.ModuleSYBInnerCodes, "match", i.Rematch},
{"POST", "/syb-inner-codes/import", access.ModuleSYBInnerCodes, "import", i.Import},
{"POST", "/syb-inner-codes/batch-delete", access.ModuleSYBInnerCodes, "delete", i.Delete},
{"POST", "/syb-inner-codes/apply-preview", access.ModuleSYBInnerCodes, "writeback", i.ApplyPreview},
{"POST", "/syb-inner-codes/apply", access.ModuleSYBInnerCodes, "writeback", i.Apply},
{"POST", "/syb-inner-codes/:recordId/recheck", access.ModuleSYBInnerCodes, "match", i.Recheck},
{"GET", "/syb-shops", access.ModuleSYBShops, "read", shop.List},
{"POST", "/syb-shops", access.ModuleSYBShops, "write", shop.Create},
{"PATCH", "/syb-shops/:shopId/name", access.ModuleSYBShops, "write", shop.Rename},
{"PATCH", "/syb-shops/:shopId/enabled", access.ModuleSYBShops, "write", shop.SetEnabled},
{"DELETE", "/syb-shops/:shopId", access.ModuleSYBShops, "delete", shop.Delete},
{"GET", "/collection-rules", access.ModuleCollectionRules, "read", r.List},
{"POST", "/collection-rules", access.ModuleCollectionRules, "write", r.Create},
{"PATCH", "/collection-rules/:ruleId", access.ModuleCollectionRules, "write", r.Update},
{"DELETE", "/collection-rules/:ruleId", access.ModuleCollectionRules, "delete", r.Delete},
{"GET", "/purchase-rules", access.ModulePurchaseRules, "read", pr.List},
{"GET", "/purchase-rules/current", access.ModulePurchaseRules, "read", pr.Current},
{"POST", "/purchase-rules", access.ModulePurchaseRules, "write", pr.Create},
{"PATCH", "/purchase-rules/:ruleId", access.ModulePurchaseRules, "write", pr.Update},
{"DELETE", "/purchase-rules/:ruleId", access.ModulePurchaseRules, "delete", pr.Delete},
{"PUT", "/purchase-rules/current", access.ModulePurchaseRules, "activate", pr.SetCurrent},
{"GET", "/collection-tasks", access.ModuleCollectionTasks, "read", t.AdminList},
{"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/:taskId/reset", access.ModuleCollectionTasks, "collect", t.AdminReset},
{"DELETE", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "delete", t.AdminDelete},
{"GET", "/purchase-tasks", access.ModulePurchaseTasks, "read", buy.AdminList},
{"GET", "/purchase-tasks/:taskId", access.ModulePurchaseTasks, "read", buy.AdminDetail},
{"POST", "/purchase-tasks/batch-preview", access.ModulePurchaseTasks, "purchase", buy.AdminBatchPreview},
{"POST", "/purchase-tasks", access.ModulePurchaseTasks, "purchase", buy.AdminCreate},
{"POST", "/purchase-tasks/batch", access.ModulePurchaseTasks, "purchase", buy.AdminBatchCreate},
{"POST", "/purchase-tasks/batch-retry", access.ModulePurchaseTasks, "purchase", buy.AdminBatchRetry},
{"POST", "/purchase-tasks/stock", access.ModulePurchaseTasks, "purchase", buy.AdminCreateStock},
{"GET", "/ai-matching-settings", access.ModuleAIMatching, "read", func(c *gin.Context) {
db, err := pkg.GetOrm(c)
if err != nil {
c.JSON(500, gin.H{"code": 500})
return
}
v, err := aimatching.NewService(db).Settings(c.Request.Context())
if err != nil {
c.JSON(500, gin.H{"code": 500})
return
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"enabled": v.Enabled}})
}},
}
}
func Catalog(routes []Endpoint) []clientkey.Module {
mods := map[string]clientkey.Module{}
for _, m := range access.GoAutoModules() {
mods[m.Key] = clientkey.Module{Key: m.Key, Title: m.Title, Actions: []string{}}
}
for _, e := range routes {
m := mods[e.Module]
if e.Capability == "write" {
m.Writable = true
} else if e.Capability != "read" {
found := false
for _, a := range m.Actions {
if a == e.Capability {
found = true
}
}
if !found {
m.Actions = append(m.Actions, e.Capability)
}
}
mods[e.Module] = m
}
out := []clientkey.Module{}
for _, g := range access.GoAutoMenuGroups() {
for _, key := range g.ModuleKeys {
m := mods[key]
m.Group = g.Title
sort.Strings(m.Actions)
out = append(out, m)
}
}
return out
}
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
routes := Inventory()
catalog := Catalog(routes)
h := clientkey.Handler{Modules: catalog}
management := engine.Group("/api/admin/v1/client-keys", auth.MiddlewareFunc(), middleware.RequireRoleKey("admin"), NoStore)
management.GET("", h.List)
management.GET("/modules", h.Catalog)
management.POST("", h.Create)
management.PATCH("/:keyId/grants", h.Edit)
management.POST("/:keyId/disable", h.Disable)
for _, e := range routes {
engine.Handle(e.Method, "/api/client/v1"+e.Path, Gate(nil, e), e.Handle)
}
}
+127
View File
@@ -0,0 +1,127 @@
package clientkey
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"gorm.io/gorm"
)
type Handler struct {
DB *gorm.DB
Modules []Module
}
func (h Handler) service(c *gin.Context) (Service, bool) {
db := h.DB
var err error
if db == nil {
db, err = pkg.GetOrm(c)
}
if err != nil || db == nil {
failure(c, errors.New("database unavailable"))
return Service{}, false
}
c.Header("Cache-Control", "no-store")
return Service{db, h.Modules}, true
}
func failure(c *gin.Context, err error) {
status, message := 500, "服务端处理失败"
switch {
case errors.Is(err, ErrInvalid):
status, message = 422, ErrInvalid.Error()
case errors.Is(err, ErrConflict):
status, message = 409, ErrConflict.Error()
case errors.Is(err, gorm.ErrRecordNotFound):
status, message = 404, "密钥不存在"
}
c.AbortWithStatusJSON(status, gin.H{"code": status, "message": message})
}
func (h Handler) Catalog(c *gin.Context) { c.JSON(200, gin.H{"code": 200, "data": h.Modules}) }
func (h Handler) List(c *gin.Context) {
s, ok := h.service(c)
if !ok {
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
size := 20
var total int64
var keys []Key
if err := s.DB.WithContext(c.Request.Context()).Model(&Key{}).Count(&total).Error; err != nil {
failure(c, err)
return
}
if err := s.DB.WithContext(c.Request.Context()).Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&keys).Error; err != nil {
failure(c, err)
return
}
items := make([]View, 0, len(keys))
for _, k := range keys {
items = append(items, view(k))
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"items": items, "total": total, "page": page, "pageSize": size}})
}
func decode(c *gin.Context, v any) bool {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
d := json.NewDecoder(c.Request.Body)
d.DisallowUnknownFields()
if d.Decode(v) != nil || d.Decode(&struct{}{}) != io.EOF {
failure(c, ErrInvalid)
return false
}
return true
}
func (h Handler) Create(c *gin.Context) {
var req struct {
Name string `json:"name"`
Grants []Grant `json:"grants"`
}
if !decode(c, &req) {
return
}
s, ok := h.service(c)
if !ok {
return
}
result, secret, err := s.Create(c.Request.Context(), req.Name, req.Grants, uint64(user.GetUserId(c)))
if err != nil {
failure(c, err)
return
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"key": result, "secret": secret}})
}
func (h Handler) Edit(c *gin.Context) { h.update(c, false) }
func (h Handler) Disable(c *gin.Context) { h.update(c, true) }
func (h Handler) update(c *gin.Context, disable bool) {
id, err := strconv.ParseUint(c.Param("keyId"), 10, 64)
if err != nil {
failure(c, ErrInvalid)
return
}
var req struct {
Version uint64 `json:"version"`
Grants []Grant `json:"grants"`
}
if !decode(c, &req) {
return
}
s, ok := h.service(c)
if !ok {
return
}
result, err := s.Update(c.Request.Context(), id, req.Version, uint64(user.GetUserId(c)), req.Grants, disable)
if err != nil {
failure(c, err)
return
}
c.JSON(200, gin.H{"code": 200, "data": result})
}
+222
View File
@@ -0,0 +1,222 @@
// Package clientkey implements independent, revocable client credentials (#237).
package clientkey
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"sort"
"strings"
"time"
"gorm.io/gorm"
)
var ErrInvalid = errors.New("名称或授权无效")
var ErrConflict = errors.New("密钥已停用或授权已被修改,请刷新后重试")
var ErrCredential = errors.New("客户端密钥无效或已停用")
type Grant struct {
Module string `json:"module"`
Write bool `json:"write"`
Actions []string `json:"actions"`
}
type Module struct {
Key string `json:"key"`
Title string `json:"title"`
Group string `json:"group"`
Writable bool `json:"writable"`
Actions []string `json:"actions"`
}
type Key struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:80;not null" json:"name"`
Prefix string `gorm:"size:24;not null" json:"prefix"`
Digest string `gorm:"size:64;uniqueIndex;not null" json:"-"`
GrantsJSON string `gorm:"type:text;not null" json:"-"`
Enabled bool `gorm:"not null" json:"enabled"`
Version uint64 `gorm:"not null" json:"version"`
CreatedBy uint64 `json:"createdBy"`
UpdatedBy uint64 `json:"updatedBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastUsedAt *time.Time `json:"lastUsedAt"`
}
func (Key) TableName() string { return "client_api_key" }
type Audit struct {
ID uint64 `gorm:"primaryKey"`
KeyID uint64 `gorm:"index;not null"`
ActorID uint64
Event string `gorm:"size:32;not null"`
RequestID string `gorm:"size:32;index"`
Method string `gorm:"size:10"`
Route string `gorm:"size:180"`
Status int
Changes string `gorm:"type:text"`
CreatedAt time.Time
}
func (Audit) TableName() string { return "client_api_key_audit" }
type View struct {
Key
Grants []Grant `json:"grants"`
}
func view(k Key) View {
var g []Grant
_ = json.Unmarshal([]byte(k.GrantsJSON), &g)
return View{Key: k, Grants: g}
}
type Service struct {
DB *gorm.DB
Modules []Module
}
func (s Service) Normalize(in []Grant) ([]Grant, error) {
if len(in) == 0 || len(in) > len(s.Modules) {
return nil, ErrInvalid
}
catalog := map[string]Module{}
for _, m := range s.Modules {
catalog[m.Key] = m
}
seen := map[string]bool{}
out := make([]Grant, 0, len(in))
for _, g := range in {
m, ok := catalog[g.Module]
if !ok || seen[g.Module] || (g.Write && !m.Writable) {
return nil, ErrInvalid
}
seen[g.Module] = true
allowed := map[string]bool{}
for _, a := range m.Actions {
allowed[a] = true
}
used := map[string]bool{}
actions := []string{}
for _, a := range g.Actions {
if !allowed[a] || used[a] {
return nil, ErrInvalid
}
used[a] = true
actions = append(actions, a)
}
sort.Strings(actions)
out = append(out, Grant{g.Module, g.Write, actions})
}
sort.Slice(out, func(i, j int) bool { return out[i].Module < out[j].Module })
return out, nil
}
func (s Service) Create(ctx context.Context, name string, grants []Grant, actor uint64) (View, string, error) {
name = strings.TrimSpace(name)
if name == "" || len([]rune(name)) > 80 || actor == 0 {
return View{}, "", ErrInvalid
}
g, err := s.Normalize(grants)
if err != nil {
return View{}, "", err
}
raw, _ := json.Marshal(g)
secret := make([]byte, 32)
if _, err = rand.Read(secret); err != nil {
return View{}, "", err
}
token := "gak_" + hex.EncodeToString(secret)
digest := sha256.Sum256([]byte(token))
k := Key{Name: name, Prefix: token[:16], Digest: hex.EncodeToString(digest[:]), GrantsJSON: string(raw), Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor}
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&k).Error; err != nil {
return err
}
return tx.Create(&Audit{KeyID: k.ID, ActorID: actor, Event: "create", Changes: string(raw)}).Error
})
if err != nil {
return View{}, "", err
}
return view(k), token, nil
}
func (s Service) Update(ctx context.Context, id, version, actor uint64, grants []Grant, disable bool) (View, error) {
if id == 0 || version == 0 || actor == 0 {
return View{}, ErrInvalid
}
var raw []byte
if !disable {
g, err := s.Normalize(grants)
if err != nil {
return View{}, err
}
raw, _ = json.Marshal(g)
}
var k Key
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.First(&k, id).Error; err != nil {
return err
}
changes := map[string]any{"version": version + 1, "updated_by": actor, "updated_at": time.Now().UTC()}
event := "edit"
if disable {
changes["enabled"] = false
event = "disable"
} else {
changes["grants_json"] = string(raw)
}
result := tx.Model(&Key{}).Where("id = ? AND version = ? AND enabled = ?", id, version, true).Updates(changes)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrConflict
}
diff, _ := json.Marshal(map[string]string{"before": k.GrantsJSON, "after": string(raw)})
if err := tx.Create(&Audit{KeyID: id, ActorID: actor, Event: event, Changes: string(diff)}).Error; err != nil {
return err
}
return tx.First(&k, id).Error
})
return view(k), err
}
func (s Service) Authenticate(ctx context.Context, token string) (Key, error) {
if len(token) != 68 || !strings.HasPrefix(token, "gak_") {
return Key{}, ErrCredential
}
if _, err := hex.DecodeString(token[4:]); err != nil {
return Key{}, ErrCredential
}
digest := sha256.Sum256([]byte(token))
var k Key
err := s.DB.WithContext(ctx).Where("digest = ? AND enabled = ?", hex.EncodeToString(digest[:]), true).First(&k).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return Key{}, ErrCredential
}
return k, err
}
func (k Key) Allows(module, capability string) bool {
var grants []Grant
if json.Unmarshal([]byte(k.GrantsJSON), &grants) != nil {
return false
}
for _, g := range grants {
if g.Module != module {
continue
}
if capability == "read" {
return true
}
if capability == "write" {
return g.Write
}
for _, a := range g.Actions {
if a == capability {
return true
}
}
}
return false
}
+115
View File
@@ -0,0 +1,115 @@
package clientkey
import (
"context"
"encoding/json"
"errors"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"strings"
"testing"
)
func testService(t *testing.T) Service {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
sql, _ := db.DB()
sql.SetMaxOpenConns(1)
t.Cleanup(func() { sql.Close() })
if err = db.AutoMigrate(&Key{}, &Audit{}); err != nil {
t.Fatal(err)
}
return Service{db, []Module{{Key: "products", Writable: true, Actions: []string{"delete"}}, {Key: "devices", Actions: []string{}}}}
}
func TestCredentialLifecycle(t *testing.T) {
s := testService(t)
ctx := context.Background()
v, token, err := s.Create(ctx, "Tool", []Grant{{Module: "products"}}, 7)
if err != nil {
t.Fatal(err)
}
if len(token) != 68 || v.Digest == token {
t.Fatal("invalid credential generation")
}
wire, _ := json.Marshal(v)
if strings.Contains(string(wire), token) || strings.Contains(string(wire), v.Digest) {
t.Fatal("secret serialized")
}
k, err := s.Authenticate(ctx, token)
if err != nil || !k.Allows("products", "read") || k.Allows("products", "write") || k.Allows("devices", "read") {
t.Fatal("default grants")
}
updated, err := s.Update(ctx, k.ID, 1, 8, []Grant{{Module: "products", Write: true, Actions: []string{"delete"}}}, false)
if err != nil {
t.Fatal(err)
}
k, err = s.Authenticate(ctx, token)
if err != nil || !k.Allows("products", "write") || !k.Allows("products", "delete") || updated.Version != 2 || k.Digest != v.Digest {
t.Fatal("edit changed credential or failed to apply grants")
}
if _, err = s.Update(ctx, k.ID, 1, 8, []Grant{{Module: "devices"}}, false); !errors.Is(err, ErrConflict) {
t.Fatal("stale edit accepted")
}
if _, err = s.Update(ctx, k.ID, 2, 8, nil, true); err != nil {
t.Fatal(err)
}
if _, err = s.Authenticate(ctx, token); !errors.Is(err, ErrCredential) {
t.Fatal("disabled credential accepted")
}
if _, err = s.Update(ctx, k.ID, 3, 8, []Grant{{Module: "products"}}, false); !errors.Is(err, ErrConflict) {
t.Fatal("disabled key edited")
}
var logs []Audit
s.DB.Find(&logs)
if len(logs) != 3 {
t.Fatalf("audit count %d", len(logs))
}
data, _ := json.Marshal(logs)
if strings.Contains(string(data), token) {
t.Fatal("secret in audit")
}
}
func TestInvalidGrantsAndCredentials(t *testing.T) {
s := testService(t)
for _, g := range [][]Grant{nil, {{Module: "admin"}}, {{Module: "devices", Write: true}}, {{Module: "products", Actions: []string{"payment"}}}, {{Module: "products"}, {Module: "products"}}} {
if _, err := s.Normalize(g); err == nil {
t.Fatal("invalid grant accepted")
}
}
for _, token := range []string{"", "jwt", "gak_" + strings.Repeat("z", 64), strings.Repeat("a", 68)} {
if _, err := s.Authenticate(context.Background(), token); !errors.Is(err, ErrCredential) {
t.Fatal("invalid credential accepted")
}
}
}
func TestAuditFailureRollsBack(t *testing.T) {
s := testService(t)
ctx := context.Background()
v, _, err := s.Create(ctx, "Tool", []Grant{{Module: "products"}}, 1)
if err != nil {
t.Fatal(err)
}
if err = s.DB.Migrator().DropTable(&Audit{}); err != nil {
t.Fatal(err)
}
if _, err = s.Update(ctx, v.ID, 1, 1, nil, true); err == nil {
t.Fatal("audit failure ignored")
}
var k Key
s.DB.First(&k, v.ID)
if !k.Enabled || k.Version != 1 {
t.Fatal("mutation not rolled back")
}
if _, secret, err := s.Create(ctx, "Failed", []Grant{{Module: "products"}}, 1); err == nil || secret != "" {
t.Fatal("creation audit failure ignored")
}
var count int64
s.DB.Model(&Key{}).Count(&count)
if count != 1 {
t.Fatal("key persisted without audit")
}
}
+4
View File
@@ -34,7 +34,11 @@ func MigratedModels() []any {
&models.PDDProduct{},
&models.AIMatchingSetting{},
&models.ShopeeProduct{},
&models.ShopeeSpecAutoMatchRun{},
&models.ShopeeSpecAutoMatchWorkItem{},
&models.SYBProduct{},
&models.SYBSpecAIParseRun{},
&models.SYBSpecAIParseWorkItem{},
&models.SYBSession{},
&models.SYBShop{},
&models.SYBSyncRun{},
+9
View File
@@ -536,6 +536,15 @@ type SYBProduct struct {
// recording the parser's own last output for audit even after a manual
// correction; it is not overwritten by the correction itself.
ManuallyConfirmed bool `json:"manuallyConfirmed" gorm:"not null;default:false"`
// AIConfirmed is independent of ParseStatus: ParseStatus remains the
// deterministic parser's audit result, while these fields record a closed-
// candidate, high-confidence AI decision. Human correction always clears
// and supersedes this decision.
AIConfirmed bool `json:"aiConfirmed" gorm:"not null;default:false;index"`
AIConfidence *float64 `json:"aiConfidence,omitempty"`
AIReason string `json:"aiReason,omitempty" gorm:"size:500;not null;default:''"`
AIConfirmedAt *time.Time `json:"aiConfirmedAt,omitempty"`
AIInputFingerprint string `json:"-" gorm:"size:64;not null;default:'';index"`
// RawJSON is the untouched `details[]` element as SYB returned it. It is
// what reparse (#41: "适用于解析规则更新后批量重跑,只读取已保存的原始
@@ -0,0 +1,56 @@
package models
import "time"
// ShopeeSpecAutoMatchRun is one scheduled or administrator-triggered batch.
// ActiveSlot is 1 only while running; its nullable unique index is the
// database-level cross-process mutex shared by both trigger paths.
type ShopeeSpecAutoMatchRun struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
RequestID string `json:"requestId" gorm:"size:36;not null;uniqueIndex:ux_shopee_spec_auto_match_run_request"`
Trigger string `json:"trigger" gorm:"size:16;not null;index"`
Status string `json:"status" gorm:"size:24;not null;index"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_shopee_spec_auto_match_run_active"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
RequestedBy *uint64 `json:"requestedBy,omitempty"`
BatchLimit int `json:"batchLimit" gorm:"not null;default:20"`
ScannedCount int `json:"scannedCount" gorm:"not null;default:0"`
EligibleCount int `json:"eligibleCount" gorm:"not null;default:0"`
ProcessedCount int `json:"processedCount" gorm:"not null;default:0"`
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
FailedCount int `json:"failedCount" gorm:"not null;default:0"`
ErrorSummary string `json:"errorSummary,omitempty" gorm:"size:500;not null;default:''"`
StartedAt time.Time `json:"startedAt" gorm:"not null"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ShopeeSpecAutoMatchRun) TableName() string { return "shopee_spec_auto_match_run" }
// ShopeeSpecAutoMatchWorkItem remembers the last input fingerprint and retry
// state for each product, preventing unchanged low-confidence inputs from
// repeatedly spending AI calls.
type ShopeeSpecAutoMatchWorkItem struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
ShopeeProductID uint64 `json:"shopeeProductId" gorm:"not null;uniqueIndex:ux_shopee_spec_auto_match_work_product"`
RunID *uint64 `json:"runId,omitempty" gorm:"index"`
InputFingerprint string `json:"inputFingerprint" gorm:"size:128;not null;default:'';index"`
Status string `json:"status" gorm:"size:24;not null;index"`
AttemptCount int `json:"attemptCount" gorm:"not null;default:0"`
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty" gorm:"index"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
LastErrorCode string `json:"lastErrorCode,omitempty" gorm:"size:64;not null;default:''"`
LastError string `json:"lastError,omitempty" gorm:"size:500;not null;default:''"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ShopeeSpecAutoMatchWorkItem) TableName() string {
return "shopee_spec_auto_match_work_item"
}
@@ -0,0 +1,48 @@
package models
import "time"
// SYBSpecAIParseRun is one globally serialized scheduled batch.
type SYBSpecAIParseRun struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
RequestID string `json:"requestId" gorm:"size:36;not null;uniqueIndex:ux_syb_spec_ai_parse_run_request"`
Trigger string `json:"trigger" gorm:"size:16;not null;index"`
Status string `json:"status" gorm:"size:24;not null;index"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_syb_spec_ai_parse_run_active"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
BatchLimit int `json:"batchLimit" gorm:"not null;default:20"`
ScannedCount int `json:"scannedCount" gorm:"not null;default:0"`
EligibleCount int `json:"eligibleCount" gorm:"not null;default:0"`
ProcessedCount int `json:"processedCount" gorm:"not null;default:0"`
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
FailedCount int `json:"failedCount" gorm:"not null;default:0"`
ErrorSummary string `json:"errorSummary,omitempty" gorm:"size:500;not null;default:''"`
StartedAt time.Time `json:"startedAt" gorm:"not null"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SYBSpecAIParseRun) TableName() string { return "syb_spec_ai_parse_run" }
// SYBSpecAIParseWorkItem prevents unchanged ambiguous input from repeatedly
// spending provider calls and owns the per-row recovery lease.
type SYBSpecAIParseWorkItem struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
SYBProductID uint64 `json:"sybProductId" gorm:"not null;uniqueIndex:ux_syb_spec_ai_parse_work_product"`
RunID *uint64 `json:"runId,omitempty" gorm:"index"`
InputFingerprint string `json:"inputFingerprint" gorm:"size:64;not null;default:'';index"`
Status string `json:"status" gorm:"size:24;not null;index"`
AttemptCount int `json:"attemptCount" gorm:"not null;default:0"`
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty" gorm:"index"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
LastErrorCode string `json:"lastErrorCode,omitempty" gorm:"size:64;not null;default:''"`
LastError string `json:"lastError,omitempty" gorm:"size:500;not null;default:''"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SYBSpecAIParseWorkItem) TableName() string { return "syb_spec_ai_parse_work_item" }
+13 -6
View File
@@ -2,7 +2,6 @@ package purchase
import (
"context"
"strings"
"testing"
"time"
@@ -72,6 +71,11 @@ func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) {
setCollectedPDDPrice(t, db, f.pdd.ID)
service := testService(db)
failed := failedLiveTask(t, db, service, f)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
"target_color": "当前黑色", "target_size": "均碼",
}).Error; err != nil {
t.Fatal(err)
}
history, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token)
if err != nil || !history.Task.Retryable || history.Task.RetryDisabledReason != "" {
@@ -93,6 +97,9 @@ func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) {
if oldTask.Status != models.PurchaseTaskStatusFailed || newTask.Status != models.PurchaseTaskStatusPending || newTask.DeviceID == nil || *newTask.DeviceID != f.device.ID || newTask.AddressSuffix == oldTask.AddressSuffix {
t.Fatalf("retry did not preserve old task or fix new task to device: old=%+v new=%+v", oldTask, newTask)
}
if newTask.TargetColorSnapshot != "当前黑色" || newTask.TargetSizeSnapshot != "均碼" || newTask.SpecSource != "unresolved" || purchaseAttemptPhase(newTask) != models.PurchaseAttemptPhaseSpecProbe {
t.Fatalf("retry did not use current SYB targets and a fresh probe: %+v", newTask)
}
replay, err := service.AgentRetry(context.Background(), failed.ID, request, f.token)
if err != nil || !replay.Replayed || replay.TaskID != first.TaskID {
@@ -158,7 +165,7 @@ func TestMatchedReplacementExposesContinueAndReusesAgentRetry(t *testing.T) {
}
}
func TestMatchedReplacementRejectsClearedMappingWithoutCallingAI(t *testing.T) {
func TestMatchedReplacementAllowsRetryWithoutPersistedMappingAndDoesNotCallAI(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f.pdd.ID)
@@ -172,11 +179,11 @@ func TestMatchedReplacementRejectsClearedMappingWithoutCallingAI(t *testing.T) {
service.Matcher = matcher
detail, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token)
if err != nil || detail.ContinuePurchaseEligible || !strings.Contains(detail.ContinuePurchaseDisabledReason, "规格匹配已失效") || matcher.calls != 0 {
if err != nil || !detail.ContinuePurchaseEligible || detail.ContinuePurchaseDisabledReason != "" || matcher.calls != 0 {
t.Fatalf("cleared mapping qualification mismatch: detail=%+v calls=%d error=%v", detail, matcher.calls, err)
}
_, err = service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token)
if code(err) != CodeMappingRequired || matcher.calls != 0 {
t.Fatalf("cleared mapping reached AI or retry: code=%s calls=%d error=%v", code(err), matcher.calls, err)
created, err := service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || created.TaskID == 0 || matcher.calls != 0 {
t.Fatalf("cleared mapping retry did not create probe task: result=%+v calls=%d error=%v", created, matcher.calls, err)
}
}
@@ -32,10 +32,44 @@ type skuCombinationRow struct {
}
func sybSpecsTrusted(syb models.SYBProduct) bool {
if syb.ParseStatus == models.SYBParseStatusFailed {
if strings.TrimSpace(syb.TargetColor) == "" && strings.TrimSpace(syb.TargetSize) == "" {
return false
}
return strings.TrimSpace(syb.TargetColor) != "" || strings.TrimSpace(syb.TargetSize) != ""
return syb.ParseStatus != models.SYBParseStatusFailed || syb.ManuallyConfirmed || syb.AIConfirmed
}
func aiConfirmedSpecsCurrent(syb models.SYBProduct, shopee models.ShopeeProduct) bool {
if !syb.AIConfirmed {
return true
}
specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON)
if err != nil {
return false
}
wanted := map[string]string{shopeeproduct.RoleColor: strings.TrimSpace(syb.TargetColor), shopeeproduct.RoleSize: strings.TrimSpace(syb.TargetSize)}
foundAny := false
for role, target := range wanted {
if target == "" {
continue
}
foundAny = true
found := false
for _, dimension := range specs {
if dimension.Role != role {
continue
}
for _, value := range dimension.Values {
if value.Name == target {
found = true
break
}
}
}
if !found {
return false
}
}
return foundAny
}
func (s *Service) loadLatestSKUCombinations(ctx context.Context, pddIDs []uint64, dataset *batchPreviewDataset) error {
+9 -70
View File
@@ -105,8 +105,7 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba
items := make([]BatchPreviewItem, 0, len(ids))
eligible := 0
for _, id := range ids {
item := s.previewFromDataset(ctx, id, dataset, false, guard)
item.enforcePersistedMatch(id, dataset)
item := s.previewFromDataset(id, dataset, guard)
item.applyCollectionEligibility(id, dataset)
qualification := aiMatchQualificationForDataset(id, dataset)
item.AIMatchEligible = qualification.Eligible
@@ -331,22 +330,6 @@ func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64, rul
return ensureCapabilities(record, purchasecontract.RequiredCapabilities(rule))
}
func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem {
_, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
if ruleErr != nil {
item := BatchPreviewItem{SYBProductID: id}
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(ruleErr)
return item
}
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
if err != nil {
item := BatchPreviewItem{SYBProductID: id}
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
return item
}
return s.previewFromDataset(ctx, id, dataset, true, purchasecontract.EffectivePriceGuard(currentRule))
}
func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard purchasecontract.PriceLimits) BatchPreviewItem {
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
if err != nil {
@@ -354,8 +337,7 @@ func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
return item
}
item := s.previewFromDataset(ctx, id, dataset, false, guard)
item.enforcePersistedMatch(id, dataset)
item := s.previewFromDataset(id, dataset, guard)
return item
}
@@ -379,7 +361,7 @@ func taskPointerValue(value *uint64) uint64 {
return *value
}
func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool, guard purchasecontract.PriceLimits) BatchPreviewItem {
func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, guard purchasecontract.PriceLimits) BatchPreviewItem {
item := BatchPreviewItem{SYBProductID: id}
syb, found := dataset.sybByID[id]
if !found {
@@ -406,6 +388,10 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee"
return item
}
if !aiConfirmedSpecsCurrent(syb, shopee) {
item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "AI 解析依据已变化,请等待重新解析或人工修正", "reparse"
return item
}
if shopee.PDDProductID == nil {
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_LINKED", "尚未关联 PDD 商品,请先关联", processActionOpenPDDLink
return item
@@ -423,26 +409,15 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
}
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
item.MappedColor, item.MappedSize = mappedColor, mappedSize
candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
candidates, _ := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
// #190:失效的已保存映射不再拦截,降级为 unresolved,与创建路径保持一致。
if source != "unresolved" && !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
mappedColor, mappedSize, source = "", "", "unresolved"
item.MappedColor, item.MappedSize = "", ""
}
if source == "unresolved" {
if !archiveUsable {
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "关联的 PDD 商品没有完整可选规格", "open_pdd"
return item
}
request := aimatching.MatchRequest{TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
if allowAI {
matched, matchErr := s.matcher().Resolve(ctx, request)
if matchErr != nil {
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(purchaseMatchError(matchErr))
return item
}
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
} else if matched, ok := aimatching.DeterministicMatch(request); ok {
if matched, ok := aimatching.DeterministicMatch(request); ok {
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
}
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
@@ -476,42 +451,6 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
return item
}
// enforcePersistedMatch is the #188 gate for the SYB list and batch-create
// workflow. A transient deterministic result is not purchase readiness: the
// color/size mapping must already be confirmed on the Shopee product and must
// still identify one complete, available SKU combination when collection
// evidence exists. Manually entered or externally imported PDD products may
// legitimately have no such collection record.
func (item *BatchPreviewItem) enforcePersistedMatch(id uint64, dataset batchPreviewDataset) {
if item.ReasonCode != "" && item.ReasonCode != CodeMappingRequired {
return
}
syb, ok := dataset.sybByID[id]
if !ok || syb.ShopeeProductID == nil {
return
}
shopee, ok := dataset.shopeeByID[*syb.ShopeeProductID]
if !ok || shopee.PDDProductID == nil {
return
}
pdd, ok := dataset.pddByID[*shopee.PDDProductID]
if !ok || pdd.Status != "active" {
return
}
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
item.MappedColor, item.MappedSize = mappedColor, mappedSize
if source == "unresolved" {
item.Eligible = false
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "规格尚未匹配并保存,请先完成 AI 匹配或人工确认", "open_mapping"
return
}
combinations := dataset.skuCombinationsByPDD[pdd.ID]
if len(combinations) > 0 && !validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
item.Eligible = false
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "已保存规格映射不属于当前可售的 PDD 规格组合,请重新匹配", "open_mapping"
}
}
func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceLimits) (reference, minPrice, maxPrice int64, err error) {
var dimensions []productspec.Dimension
if json.Unmarshal([]byte(raw), &dimensions) != nil {
@@ -9,7 +9,6 @@ import (
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -137,18 +136,14 @@ func exactBatchSpecFixture(t *testing.T) (*Service, fixture) {
return testService(db), f
}
func TestBatchSpecMatchPersistsExactMatchBeforePurchaseCreation(t *testing.T) {
func TestBatchSpecMatchPersistsExactMatchButPurchaseDoesNotWaitForIt(t *testing.T) {
service, f := exactBatchSpecFixture(t)
matcher := &batchSpecMatcher{}
service.Matcher = matcher
before, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil || len(before.Items) != 1 || before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
t.Fatalf("unpersisted exact match was not gated: %+v err=%v", before, err)
}
blocked, err := service.BatchCreate(context.Background(), BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID})
if err != nil || blocked.CreatedCount != 0 || blocked.FailedCount != 1 || blocked.Items[0].ReasonCode != CodeMappingRequired {
t.Fatalf("batch create bypassed persisted mapping gate: %+v err=%v", blocked, err)
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStagePurchaseReady {
t.Fatalf("live-probe purchase was incorrectly gated by persisted mapping: %+v err=%v", before, err)
}
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
@@ -186,8 +181,8 @@ func TestExtractedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T) {
service.Matcher = matcher
before, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil || len(before.Items) != 1 || before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
t.Fatalf("extracted uncertain specs did not enter matching: %+v err=%v", before, err)
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStagePurchaseReady {
t.Fatalf("extracted uncertain specs were not purchase-ready for live probing: %+v err=%v", before, err)
}
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil || matched.AutoConfirmedCount != 1 || matcher.calls != 0 {
@@ -236,7 +231,7 @@ func TestBatchPreviewExposesExplicitAIMatchEligibility(t *testing.T) {
t.Fatalf("preview failed: %+v err=%v", preview, err)
}
item := preview.Items[0]
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || item.Eligible || item.ProcessStage != ProcessStageColorMapping {
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || !item.Eligible || item.ProcessStage != ProcessStagePurchaseReady {
t.Fatalf("explicit AI eligibility mismatch: %+v", item)
}
}
+43 -2
View File
@@ -54,6 +54,9 @@ func TestSybSpecsTrustedOnlyBlocksFailedOrEmptyExtraction(t *testing.T) {
{"uncertain with color", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, TargetColor: "套装"}, true},
{"uncertain with size", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, TargetSize: "均码"}, true},
{"failed with values", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, TargetColor: "黑色"}, false},
{"failed AI confirmed", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, TargetColor: "黑色", AIConfirmed: true}, true},
{"failed manually confirmed", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, TargetColor: "黑色", ManuallyConfirmed: true}, true},
{"AI confirmed without values", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, AIConfirmed: true}, false},
{"uncertain without values", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain}, false},
}
for _, tt := range tests {
@@ -65,6 +68,28 @@ func TestSybSpecsTrustedOnlyBlocksFailedOrEmptyExtraction(t *testing.T) {
}
}
func TestSYBSpecsTrustedAcceptsAIWithoutCallingItManual(t *testing.T) {
row := models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, TargetColor: "黑色", AIConfirmed: true}
if !sybSpecsTrusted(row) {
t.Fatal("a valid AI-confirmed parse must pass the purchase parse gate")
}
if row.ManuallyConfirmed {
t.Fatal("AI confirmation must not be represented as human confirmation")
}
}
func TestAIConfirmedSpecsBecomeUntrustedWhenShopeeCandidateDisappears(t *testing.T) {
row := models.SYBProduct{TargetColor: "黑色", TargetSize: "XL", ParseStatus: models.SYBParseStatusUncertain, AIConfirmed: true}
product := models.ShopeeProduct{SpecsJSON: `[{"name":"颜色","role":"color","values":[{"name":"白色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`}
if aiConfirmedSpecsCurrent(row, product) {
t.Fatal("removed Shopee candidate must invalidate the AI parse gate")
}
product.SpecsJSON = `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`
if !aiConfirmedSpecsCurrent(row, product) {
t.Fatal("unchanged exact Shopee candidates should keep AI parse valid")
}
}
func TestBatchPreviewExposesIndependentCollectionEligibility(t *testing.T) {
db := testDB(t)
fixture := seed(t, db, liveCaps(), true)
@@ -288,8 +313,24 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
if queries != 7 {
t.Fatalf("batch preview used %d queries, want 7 bounded queries including collection eligibility and current purchase rule", queries)
}
if len(response.Items) != 2 || response.Items[0].Eligible || response.Items[1].Eligible || response.Items[0].ReasonCode != CodeMappingRequired {
t.Fatalf("unresolved rows must not be reported as purchase-ready: %+v", response)
if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible || response.EligibleCount != 2 {
t.Fatalf("unresolved rows must be ready for mandatory live probing: %+v", response)
}
}
func TestBatchPreviewAllowsIncompleteArchivedCandidatesForMandatoryLiveProbe(t *testing.T) {
db := testDB(t)
fixture := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", fixture.pdd.ID).Update(
"specs_json",
`[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]}]`,
).Error; err != nil {
t.Fatal(err)
}
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID}})
if err != nil || len(response.Items) != 1 || !response.Items[0].Eligible || response.Items[0].ProcessStage != ProcessStagePurchaseReady {
t.Fatalf("incomplete archive blocked live probe task creation: %+v err=%v", response, err)
}
}
+7
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"go-admin/common/clientprincipal"
"io"
"net/http"
"strconv"
@@ -470,6 +471,9 @@ func writeAdminReplay(c *gin.Context, data any, replayed bool) {
}
func allowedOperator(c *gin.Context) bool {
if _, ok := clientprincipal.Get(c); ok {
return true
}
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
if role == "admin" || role == "purchaser" {
return true
@@ -544,6 +548,9 @@ func writeError(c *gin.Context, err error) {
c.JSON(status, gin.H{"code": code, "message": msg, "retryable": retryable})
}
func operatorID(c *gin.Context) uint64 {
if id, ok := clientprincipal.Get(c); ok {
return id.AuthorizedBy
}
claims := jwt.ExtractClaims(c)
switch v := claims["identity"].(type) {
case float64:
+43 -20
View File
@@ -33,6 +33,19 @@ func (s *Service) Next(ctx context.Context, token string) (*TaskPayload, error)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, internal(err)
}
// A completed probe reserves the device's purchase flow while the server
// resolves the exact specs. Returning that task as a waiting payload keeps
// the Agent from claiming another purchase or collection task and preserves
// the PDD page that the probe just inspected.
var waiting models.PurchaseTask
if err = s.DB.WithContext(ctx).
Where("device_id = ? AND status = ?", d.ID, models.PurchaseTaskStatusSpecProbePending).
Order("created_at, id").
First(&waiting).Error; err == nil {
return s.payload(waiting, nil, false)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, internal(err)
}
now := s.Now()
var candidates []models.PurchaseTask
if err = s.DB.WithContext(ctx).Where("status IN ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?) AND (device_id IS NULL OR device_id = ?)", []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now, d.ID).Order("CASE WHEN device_id IS NULL THEN 1 ELSE 0 END, created_at, id").Limit(100).Find(&candidates).Error; err != nil {
@@ -186,17 +199,14 @@ func (s *Service) Start(ctx context.Context, taskID uint64, req ActionRequest, t
if e = ensureAccountFree(tx, t.PDDAccountID, t.ID, s.Now()); e != nil {
return e
}
phase := models.PurchaseAttemptPhasePurchase
if t.SpecSource == "unresolved" {
phase = models.PurchaseAttemptPhaseSpecProbe
}
phase := purchaseAttemptPhase(t)
ruleSnapshotHash := purchaseRuleSnapshotHash(t.RuleSnapshot)
now := s.Now()
var a models.PurchaseTaskAttempt
if e = tx.Where("task_id = ? AND status = ?", t.ID, models.PurchaseAttemptStatusPending).Order("attempt_number DESC, id DESC").First(&a).Error; e == nil {
if a.DeviceID == nil || *a.DeviceID != d.ID || a.RuleSnapshotHash != ruleSnapshotHash {
if a.DeviceID == nil || *a.DeviceID != d.ID || a.RuleSnapshotHash != ruleSnapshotHash || a.Phase != phase {
failureCode := CodeStateConflict
message := "采购规则快照校验失败,请重新重试任务"
message := "采购任务执行快照校验失败,请重新创建任务"
a.Status = models.PurchaseAttemptStatusFailed
a.ErrorCode = &failureCode
a.ErrorMessage = &message
@@ -266,6 +276,9 @@ func (s *Service) MarkOrderSubmitStarted(ctx context.Context, taskID uint64, req
if t.ExecutionMode != models.PurchaseExecutionModeLive {
return TaskPayload{}, fail(CodeStateConflict, "演练任务不能创建订单")
}
if a.Phase != models.PurchaseAttemptPhasePurchase {
return TaskPayload{}, fail(CodeStateConflict, "规格探测阶段不能进入创建订单边界")
}
if _, e := uuid.Parse(req.RequestID); e != nil {
return TaskPayload{}, fail(CodeInvalidRequest, "requestId 无效")
}
@@ -536,19 +549,29 @@ func (s *Service) resolveProbedSpecs(ctx context.Context, taskID uint64, attempt
}
decision.NoMatch, decision.Decision = true, snapshot
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码"
} else if matched, matchErr := s.matcher().Resolve(ctx, request); matchErr == nil {
snapshot, marshalErr := json.Marshal(matched.Decision)
if marshalErr != nil {
return TaskPayload{}, internal(marshalErr)
}
decision.MappedColor, decision.MappedSize, decision.Source, decision.Decision = matched.MappedColor, matched.MappedSize, matched.Source, snapshot
} else {
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, purchaseMatchReason(matchErr)))
if marshalErr != nil {
return TaskPayload{}, internal(marshalErr)
matched, matchErr := s.resolveProbedMatch(ctx, task, request)
valid := matchErr == nil && (matched.Source == "manual_mapping" || matched.Source == aimatching.SourceExact || matched.Source == aimatching.SourceAI) &&
matchCandidateValid(request.TargetColor, matched.MappedColor, request.Colors) &&
matchCandidateValid(request.TargetSize, matched.MappedSize, request.Sizes)
if valid {
snapshot, marshalErr := json.Marshal(matched.Decision)
if marshalErr != nil {
return TaskPayload{}, internal(marshalErr)
}
decision.MappedColor, decision.MappedSize, decision.Source, decision.Decision = matched.MappedColor, matched.MappedSize, matched.Source, snapshot
} else {
reason := purchaseMatchReason(matchErr)
if matchErr == nil {
reason = "AI 规格匹配结果不属于当次 PDD 候选"
}
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, reason))
if marshalErr != nil {
return TaskPayload{}, internal(marshalErr)
}
decision.NoMatch, decision.Decision = true, snapshot
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", reason
}
decision.NoMatch, decision.Decision = true, snapshot
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", purchaseMatchReason(matchErr)
}
updated, _, err := s.applySpecDecision(ctx, taskID, decision, nil)
if err != nil {
@@ -567,7 +590,7 @@ func purchaseMatchReason(err error) string {
return "AI 规格匹配暂时不可用,请稍后重新创建采购任务"
}
}
return "没有找到可采购的 PDD 颜色或尺码"
return "已采集到当前规格,但未能确定颜色或尺码映射"
}
type probedSpecCandidates struct {
@@ -649,7 +672,7 @@ func (s *Service) withRunning(ctx context.Context, taskID uint64, token string,
func ensureDeviceFree(tx *gorm.DB, deviceID, taskID uint64, now time.Time) error {
var count int64
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", taskID, deviceID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}, []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).Count(&count).Error; e != nil {
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND device_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", taskID, deviceID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending}, models.PurchaseTaskStatusPending, now).Count(&count).Error; e != nil {
return internal(e)
}
if count > 0 {
@@ -668,7 +691,7 @@ func ensureAccountFree(tx *gorm.DB, accountID *uint64, taskID uint64, now time.T
return nil
}
var count int64
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND pdd_account_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", taskID, *accountID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}, []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).Count(&count).Error; e != nil {
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND pdd_account_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", taskID, *accountID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending}, models.PurchaseTaskStatusPending, now).Count(&count).Error; e != nil {
return internal(e)
}
if count > 0 {
@@ -40,6 +40,19 @@ func queuedMatchTask(t *testing.T) (*gorm.DB, fixture, models.PurchaseTask) {
if err != nil {
t.Fatal(err)
}
fingerprint, snapshot, err := currentPurchaseMatchInput(db, task)
if err != nil {
t.Fatal(err)
}
work := models.PurchaseSpecMatchWorkItem{
PurchaseTaskID: task.ID,
Status: models.PurchaseMatchPending,
InputFingerprint: fingerprint,
InputSnapshotJSON: snapshot,
}
if err := db.Create(&work).Error; err != nil {
t.Fatal(err)
}
return db, f, task
}
@@ -0,0 +1,207 @@
package purchase
import (
"context"
"encoding/json"
"strings"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
)
// resolveProbedMatch keeps the live probe as the source of executable labels.
// Confirmed product mappings may guide the decision only when they still map
// uniquely to a label observed by this attempt.
func (s *Service) resolveProbedMatch(ctx context.Context, task models.PurchaseTask, request aimatching.MatchRequest) (aimatching.MatchResult, error) {
resolvedColor, resolvedSize := "", ""
roleSources := map[string]string{}
mappings := probeGuidanceFromSnapshot(task.SpecDecisionSnapshot)
if candidate, ok := currentMappedCandidate(mappings.color.value, request.Colors); ok {
resolvedColor, roleSources[shopeeproduct.RoleColor] = candidate, mappings.color.source
}
if candidate, ok := currentMappedCandidate(mappings.size.value, request.Sizes); ok {
resolvedSize, roleSources[shopeeproduct.RoleSize] = candidate, mappings.size.source
}
if task.TargetColorSnapshot != "" && resolvedColor == "" {
if exact, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, Colors: request.Colors}); ok {
resolvedColor, roleSources[shopeeproduct.RoleColor] = exact.MappedColor, aimatching.SourceExact
}
}
if task.TargetSizeSnapshot != "" && resolvedSize == "" {
if exact, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetSize: task.TargetSizeSnapshot, Sizes: request.Sizes}); ok {
resolvedSize, roleSources[shopeeproduct.RoleSize] = exact.MappedSize, aimatching.SourceExact
}
}
missingColor := task.TargetColorSnapshot != "" && resolvedColor == ""
missingSize := task.TargetSizeSnapshot != "" && resolvedSize == ""
var providerDecision aimatching.DecisionSnapshot
if missingColor || missingSize {
remaining := aimatching.MatchRequest{}
if missingColor {
remaining.TargetColor, remaining.Colors = task.TargetColorSnapshot, request.Colors
}
if missingSize {
remaining.TargetSize, remaining.Sizes = task.TargetSizeSnapshot, request.Sizes
}
matched, err := s.matcher().Resolve(ctx, remaining)
if err != nil {
return aimatching.MatchResult{}, err
}
if matched.Source != aimatching.SourceExact && matched.Source != aimatching.SourceAI {
return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "规格匹配来源无效"}
}
providerDecision = matched.Decision
if missingColor {
if !matchCandidateValid(remaining.TargetColor, matched.MappedColor, remaining.Colors) {
return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "AI 返回的颜色不属于当次候选"}
}
resolvedColor, roleSources[shopeeproduct.RoleColor] = matched.MappedColor, matched.Source
}
if missingSize {
if !matchCandidateValid(remaining.TargetSize, matched.MappedSize, remaining.Sizes) {
return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "AI 返回的尺码不属于当次候选"}
}
resolvedSize, roleSources[shopeeproduct.RoleSize] = matched.MappedSize, matched.Source
}
}
source := probeSourceSummary(roleSources)
reason := "当次候选完成确定性匹配"
if containsRoleSource(roleSources, "manual_mapping") || containsRoleSource(roleSources, aimatching.SourceAI) {
reason = "已确认映射经当次候选验证,未解决规格按确定性或 AI 匹配"
}
result := aimatching.RecordedMatch(request, source, resolvedColor, resolvedSize, reason)
result.Decision.RoleSources = roleSources
result.Decision.Provider = providerDecision.Provider
result.Decision.Model = providerDecision.Model
result.Decision.Confidence = providerDecision.Confidence
return result, nil
}
type confirmedProbeMapping struct {
value string
source string
invalid bool
}
type confirmedProbeMappingSet struct {
color confirmedProbeMapping
size confirmedProbeMapping
}
type probeGuidanceSnapshot struct {
ConfirmedMappings map[string]probeGuidanceMapping `json:"confirmedMappings,omitempty"`
}
type probeGuidanceMapping struct {
Value string `json:"value"`
Source string `json:"source"`
}
func newProbeGuidanceSnapshot(raw, targetColor, targetSize string) (string, error) {
mappings := confirmedProbeMappings(raw, targetColor, targetSize)
snapshot := probeGuidanceSnapshot{ConfirmedMappings: map[string]probeGuidanceMapping{}}
if mappings.color.value != "" {
snapshot.ConfirmedMappings[shopeeproduct.RoleColor] = probeGuidanceMapping{Value: mappings.color.value, Source: mappings.color.source}
}
if mappings.size.value != "" {
snapshot.ConfirmedMappings[shopeeproduct.RoleSize] = probeGuidanceMapping{Value: mappings.size.value, Source: mappings.size.source}
}
encoded, err := json.Marshal(snapshot)
return string(encoded), err
}
func probeGuidanceFromSnapshot(raw string) confirmedProbeMappingSet {
var snapshot probeGuidanceSnapshot
if json.Unmarshal([]byte(raw), &snapshot) != nil {
return confirmedProbeMappingSet{}
}
result := confirmedProbeMappingSet{}
if mapping, ok := snapshot.ConfirmedMappings[shopeeproduct.RoleColor]; ok {
result.color = confirmedProbeMapping{value: strings.TrimSpace(mapping.Value), source: mapping.Source}
}
if mapping, ok := snapshot.ConfirmedMappings[shopeeproduct.RoleSize]; ok {
result.size = confirmedProbeMapping{value: strings.TrimSpace(mapping.Value), source: mapping.Source}
}
return result
}
func confirmedProbeMappings(raw, targetColor, targetSize string) confirmedProbeMappingSet {
var specs []shopeeproduct.SpecDimension
if json.Unmarshal([]byte(raw), &specs) != nil {
return confirmedProbeMappingSet{}
}
result := confirmedProbeMappingSet{}
for _, dimension := range specs {
for _, value := range dimension.Values {
if value.Mapping == nil || value.Mapping.Status != shopeeproduct.MappingStatusConfirmed {
continue
}
mapping := confirmedProbeMapping{value: strings.TrimSpace(value.Mapping.PDDValue), source: mapSource(value.Mapping.Source)}
switch {
case dimension.Role == shopeeproduct.RoleColor && value.Name == targetColor:
result.color = mergeConfirmedProbeMapping(result.color, mapping)
case dimension.Role == shopeeproduct.RoleSize && value.Name == targetSize:
result.size = mergeConfirmedProbeMapping(result.size, mapping)
}
}
}
return result
}
func mergeConfirmedProbeMapping(current, incoming confirmedProbeMapping) confirmedProbeMapping {
if current.invalid {
return current
}
if current.value == "" {
return incoming
}
if current.value != incoming.value {
return confirmedProbeMapping{invalid: true}
}
return current
}
func currentMappedCandidate(mapped string, candidates []string) (string, bool) {
mapped = strings.TrimSpace(mapped)
if mapped == "" {
return "", false
}
matches := make([]string, 0, 1)
seen := map[string]bool{}
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" || seen[candidate] || aimatching.Normalize(candidate) != aimatching.Normalize(mapped) {
continue
}
seen[candidate] = true
matches = append(matches, candidate)
}
if len(matches) != 1 {
return "", false
}
return matches[0], true
}
func probeSourceSummary(roleSources map[string]string) string {
if containsRoleSource(roleSources, "manual_mapping") {
return "manual_mapping"
}
if containsRoleSource(roleSources, aimatching.SourceAI) {
return aimatching.SourceAI
}
return aimatching.SourceExact
}
func containsRoleSource(roleSources map[string]string, target string) bool {
for _, source := range roleSources {
if source == target {
return true
}
}
return false
}
+2 -3
View File
@@ -69,8 +69,7 @@ func (s *Service) ProcessStages(ctx context.Context, ids []uint64) (map[uint64]P
return nil, err
}
for _, id := range ids {
preview := s.previewFromDataset(ctx, id, dataset, false, guard)
preview.enforcePersistedMatch(id, dataset)
preview := s.previewFromDataset(id, dataset, guard)
qualification := aiMatchQualificationForDataset(id, dataset)
preview.AIMatchEligible = qualification.Eligible
preview.AIMatchDisabledReason = qualification.DisabledReason
@@ -137,7 +136,7 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
if !ok {
return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh")
}
if syb.ParseStatus == models.SYBParseStatusFailed {
if syb.ParseStatus == models.SYBParseStatusFailed && !syb.ManuallyConfirmed && !syb.AIConfirmed {
return stage(ProcessStageManualAction, "解析失败,请先处理", "reparse")
}
if syb.ShopeeProductID == nil {
+16 -4
View File
@@ -87,6 +87,9 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
if err := validatePurchaseResetState(tx, task); err != nil {
return err
}
if task.SpecSource != "unresolved" && !hasCompletePurchaseSpec(task) {
return fail(CodeMappingRequired, "任务没有完整的精确商品规格,请创建新采购任务")
}
deviceRecord, err := lockPurchaseResetDevice(tx, task, authenticated)
if err != nil {
return err
@@ -130,10 +133,7 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
return conflictOrInternal(err)
}
phase := models.PurchaseAttemptPhasePurchase
if task.SpecSource == "unresolved" {
phase = models.PurchaseAttemptPhaseSpecProbe
}
phase := purchaseAttemptPhase(task)
// MySQL normalizes values written to a JSON column. Reload the task before
// hashing so the pending attempt uses the exact representation Start will
// read later, rather than the pre-persistence DefaultLiveRule bytes.
@@ -155,6 +155,18 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
return response, err
}
func purchaseAttemptPhase(task models.PurchaseTask) string {
if task.SpecSource == "unresolved" || !hasCompletePurchaseSpec(task) {
return models.PurchaseAttemptPhaseSpecProbe
}
return models.PurchaseAttemptPhasePurchase
}
func hasCompletePurchaseSpec(task models.PurchaseTask) bool {
return (strings.TrimSpace(task.TargetColorSnapshot) == "" || strings.TrimSpace(task.MappedColorSnapshot) != "") &&
(strings.TrimSpace(task.TargetSizeSnapshot) == "" || strings.TrimSpace(task.MappedSizeSnapshot) != "")
}
func validatePurchaseResetState(tx *gorm.DB, task models.PurchaseTask) error {
switch task.Status {
case models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderCreated, models.PurchaseTaskStatusOrderResultUnknown:
+30
View File
@@ -108,6 +108,36 @@ func TestPurchaseResetReusesTaskRefreshesRuleAndIsIdempotent(t *testing.T) {
}
}
func TestPurchaseResetRejectsResolvedDecisionWithoutExactSpec(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
service := testService(db)
failed := failedLiveTask(t, db, service, f)
decisionID := uuid.NewString()
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID).Updates(map[string]any{
"spec_source": "ai_match", "spec_decision_request_id": decisionID,
"mapped_color_snapshot": "", "mapped_size_snapshot": "",
}).Error; err != nil {
t.Fatal(err)
}
_, err := service.ResetForDevice(context.Background(), failed.ID, PurchaseResetRequest{RequestID: uuid.NewString()}, f.token)
if code(err) != CodeMappingRequired {
t.Fatalf("resolved decision without exact spec was reset: %v", err)
}
var task models.PurchaseTask
if queryErr := db.First(&task, failed.ID).Error; queryErr != nil {
t.Fatal(queryErr)
}
if task.Status != models.PurchaseTaskStatusFailed {
t.Fatalf("rejected reset changed task status: %+v", task)
}
var attemptCount int64
if queryErr := db.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", failed.ID).Count(&attemptCount).Error; queryErr != nil || attemptCount != 0 {
t.Fatalf("rejected reset created attempt: count=%d err=%v", attemptCount, queryErr)
}
}
func TestPurchaseStartPersistsPendingAttemptSnapshotMismatchAsFailure(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
+14 -15
View File
@@ -218,7 +218,12 @@ func (s *Service) retryEligibility(ctx context.Context, task models.PurchaseTask
return decision
}
preview := s.previewOne(ctx, *task.SYBProductID)
_, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
if ruleErr != nil {
code, message, _ := serviceErrorFields(ruleErr)
return retryDecision{ReasonCode: code, Reason: message}
}
preview := s.previewOneDeterministic(ctx, *task.SYBProductID, purchasecontract.EffectivePriceGuard(currentRule))
if !preview.Eligible {
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
@@ -230,10 +235,9 @@ func (s *Service) retryEligibility(ctx context.Context, task models.PurchaseTask
}
// retryQueryEligibility is deliberately limited to local task and device
// safety checks. Admin list/detail queries must never call previewOne because
// previewOne can fall back to the external AI provider for unresolved specs.
// The batch-retry command runs retryEligibility and performs that complete,
// current-archive validation only after the operator submits the retry.
// safety checks. The batch-retry command performs deterministic archive and
// price validation, but specification resolution belongs to the new task's
// mandatory live probe and never calls AI before task creation.
func (s *Service) retryQueryEligibility(ctx context.Context, task models.PurchaseTask, checkDeviceBusy bool) retryDecision {
decision := s.retryStateEligibility(ctx, task)
if !decision.Allowed {
@@ -263,25 +267,20 @@ func (s *Service) continuePurchaseEligibility(ctx context.Context, task models.P
guard := purchasecontract.EffectivePriceGuard(currentRule)
syb, found := dataset.sybByID[*task.SYBProductID]
if !found || syb.ShopeeProductID == nil {
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
shopee, found := dataset.shopeeByID[*syb.ShopeeProductID]
if !found || shopee.PDDProductID == nil {
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
pdd, found := dataset.pddByID[*shopee.PDDProductID]
_, found = dataset.pddByID[*shopee.PDDProductID]
if !found {
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
candidates, _ := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
if source == "unresolved" || !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
return retryDecision{ReasonCode: CodeMappingRequired, Reason: "规格匹配已失效,请在 Admin 重新确认"}
}
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
if !preview.Eligible {
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
+3 -3
View File
@@ -223,7 +223,7 @@ func TestAdminListRetryEligibilityRejectsBusyOriginalDevice(t *testing.T) {
}
}
func TestAdminQueriesDoNotResolveSpecsButBatchRetryStillDoes(t *testing.T) {
func TestAdminQueriesAndBatchRetryDoNotResolveArchivedSpecs(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
service := testService(db)
@@ -253,8 +253,8 @@ func TestAdminQueriesDoNotResolveSpecsButBatchRetryStillDoes(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if matcher.calls != 1 || retry.CreatedCount != 0 || retry.FailedCount != 1 || retry.Items[0].ReasonCode != CodeMappingRequired {
t.Fatalf("batch retry skipped full matching: result=%+v matcherCalls=%d", retry, matcher.calls)
if matcher.calls != 0 || retry.CreatedCount != 1 || retry.FailedCount != 0 {
t.Fatalf("batch retry did not create a mandatory probe task: result=%+v matcherCalls=%d", retry, matcher.calls)
}
}
+13 -75
View File
@@ -54,11 +54,6 @@ type SpecMatcher interface {
Resolve(context.Context, aimatching.MatchRequest) (aimatching.MatchResult, error)
}
type externalMatchPlan struct {
fingerprint string
snapshot string
}
type matchFingerprint struct {
SYBProductID uint64 `json:"sybProductId"`
TargetColor string `json:"targetColor"`
@@ -96,51 +91,6 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas
return call.task, call.replayed, call.err
}
func (s *Service) prepareExternalMatch(ctx context.Context, req CreateRequest) (*externalMatchPlan, error) {
if req.ExecutionMode != models.PurchaseExecutionModeLive || req.SYBProductID == nil {
return nil, nil
}
var syb models.SYBProduct
if err := s.DB.WithContext(ctx).First(&syb, *req.SYBProductID).Error; err != nil {
return nil, notFound(err, "顺云宝商品不存在")
}
if syb.ShopeeProductID == nil {
return nil, fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品")
}
var shopee models.ShopeeProduct
if err := s.DB.WithContext(ctx).First(&shopee, *syb.ShopeeProductID).Error; err != nil {
return nil, notFound(err, "蝦皮商品不存在")
}
if shopee.PDDProductID == nil {
return nil, fail(CodeInvalidRequest, "该蝦皮商品尚未关联拼多多商品")
}
var pdd models.PDDProduct
if err := s.DB.WithContext(ctx).First(&pdd, *shopee.PDDProductID).Error; err != nil {
return nil, notFound(err, "拼多多商品不存在")
}
targetColor, targetSize := strings.TrimSpace(req.TargetColor), strings.TrimSpace(req.TargetSize)
if targetColor == "" {
targetColor = syb.TargetColor
}
if targetSize == "" {
targetSize = syb.TargetSize
}
_, _, source := confirmedMappings(shopee.SpecsJSON, targetColor, targetSize)
candidates, usable := archiveCandidates(pdd.SpecsJSON, targetColor, targetSize)
if pdd.Status != "active" || source != "unresolved" || !usable {
return nil, nil
}
request := aimatching.MatchRequest{TargetColor: targetColor, TargetSize: targetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
if _, deterministic := aimatching.DeterministicMatch(request); deterministic {
return nil, nil
}
fingerprint, snapshot, err := creationMatchInput(syb, shopee, pdd, request)
if err != nil {
return nil, internal(err)
}
return &externalMatchPlan{fingerprint: fingerprint, snapshot: snapshot}, nil
}
func creationMatchInput(syb models.SYBProduct, shopee models.ShopeeProduct, pdd models.PDDProduct, request aimatching.MatchRequest) (string, string, error) {
canonical := func(raw string) (json.RawMessage, error) {
var value any
@@ -194,10 +144,6 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return models.PurchaseTask{}, false, internal(err)
}
externalPlan, err := s.prepareExternalMatch(ctx, req)
if err != nil {
return models.PurchaseTask{}, false, err
}
replayed := false
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("create_request_id = ?", req.RequestID).First(&out).Error; err == nil {
@@ -334,22 +280,25 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
if currency == "" {
currency = shopee.Currency
}
mappedColor, mappedSize, specSource = confirmedMappings(shopee.SpecsJSON, targetColor, targetSize)
// Every new SYB purchase must use the candidates observed on the
// current PDD page. Freeze confirmed mappings only as guidance for
// validation against that future probe; they never skip the probe.
mappedColor, mappedSize, specSource = "", "", "unresolved"
guidance, marshalErr := newProbeGuidanceSnapshot(shopee.SpecsJSON, targetColor, targetSize)
if marshalErr != nil {
return internal(marshalErr)
}
decisionSnapshot = guidance
}
candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, targetColor, targetSize)
matchRequest := aimatching.MatchRequest{TargetColor: targetColor, TargetSize: targetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
if externalPlan != nil {
fingerprint, fingerprintErr := creationMatchFingerprint(syb, shopee, pdd, matchRequest)
if fingerprintErr != nil {
return internal(fingerprintErr)
}
if fingerprint != externalPlan.fingerprint {
return fail(CodeMappingRequired, "规格匹配输入已变化,请重新创建采购任务")
}
}
if taskType == models.PurchaseTaskTypeStock {
// Direct stock selection was already validated above. It never enters
// mapping, deterministic matching, or the external AI fallback.
} else if req.ExecutionMode == models.PurchaseExecutionModeLive {
// The first attempt is always spec_probe for an SYB purchase. The
// exact task-level decision is frozen only from that probe result.
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if pdd.Status != "active" {
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if specSource == "manual_mapping" || specSource == "exact_match" || specSource == "ai_match" {
@@ -365,8 +314,6 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
}
} else if !archiveUsable {
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if externalPlan != nil {
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if match, matched := aimatching.DeterministicMatch(matchRequest); !matched {
// #190:确定性匹配失败不再拒绝,降级为 unresolved 交给规格探测。
mappedColor, mappedSize, specSource = "", "", "unresolved"
@@ -404,17 +351,8 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
if err := tx.Model(&out).Update("address_suffix", out.AddressSuffix).Error; err != nil {
return internal(err)
}
if externalPlan != nil {
work := models.PurchaseSpecMatchWorkItem{PurchaseTaskID: out.ID, Status: models.PurchaseMatchPending, InputFingerprint: externalPlan.fingerprint, InputSnapshotJSON: externalPlan.snapshot}
if err := tx.Create(&work).Error; err != nil {
return internal(err)
}
}
return nil
})
if err == nil && !replayed && externalPlan != nil && s.StartMatching != nil {
s.StartMatching(s.DB)
}
return out, replayed, err
}
+321 -25
View File
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/device"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
@@ -29,6 +30,19 @@ type fixture struct {
token string
}
type liveProbeMatcher struct {
result aimatching.MatchResult
err error
calls int
request aimatching.MatchRequest
}
func (matcher *liveProbeMatcher) Resolve(_ context.Context, request aimatching.MatchRequest) (aimatching.MatchResult, error) {
matcher.calls++
matcher.request = request
return matcher.result, matcher.err
}
func testDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared&_foreign_keys=on", strings.ReplaceAll(t.Name(), "/", "_"))
@@ -107,6 +121,30 @@ func createLive(t *testing.T, s *Service, f fixture) (models.PurchaseTask, error
r, _, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true)})
return r, err
}
func startLivePurchaseAfterProbe(t *testing.T, s *Service, f fixture, task models.PurchaseTask) TaskPayload {
t.Helper()
if _, err := s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
probeAttempt, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || probeAttempt.Phase != models.PurchaseAttemptPhaseSpecProbe {
t.Fatalf("probe start: %+v %v", probeAttempt, err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: probeAttempt.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)}
if _, err = s.SubmitResult(context.Background(), task.ID, probe, f.token); err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
purchaseAttempt, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || purchaseAttempt.Phase != models.PurchaseAttemptPhasePurchase {
t.Fatalf("purchase start: %+v %v", purchaseAttempt, err)
}
return purchaseAttempt
}
func code(err error) string {
var e *ServiceError
if errors.As(err, &e) {
@@ -224,9 +262,23 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T)
if err != nil {
t.Fatal(err)
}
if start.Phase != models.PurchaseAttemptPhasePurchase {
if start.Phase != models.PurchaseAttemptPhaseSpecProbe {
t.Fatalf("unexpected phase %s", start.Phase)
}
if _, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeStateConflict {
t.Fatalf("probe entered order boundary: %v", err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: start.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)}
if _, err = s.SubmitResult(context.Background(), task.ID, probe, f.token); err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
start, err = s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || start.Phase != models.PurchaseAttemptPhasePurchase {
t.Fatalf("purchase start: %+v %v", start, err)
}
if _, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
@@ -243,7 +295,7 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T)
}
var count int64
db.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", task.ID).Count(&count)
if count != 1 || first.Status != models.PurchaseTaskStatusOrderCreated {
if count != 2 || first.Status != models.PurchaseTaskStatusOrderCreated {
t.Fatalf("duplicate attempt or wrong status: %d %+v", count, first)
}
var stored models.PurchaseTask
@@ -344,6 +396,198 @@ func TestSlowPathUsesTwoAttemptsAndFreezesDecision(t *testing.T) {
}
}
func TestLiveProbeCallsAIOnlyAfterCandidatesAreReturned(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil {
t.Fatal(err)
}
request := aimatching.MatchRequest{TargetColor: "象牙白", TargetSize: "XL", Colors: []string{"米白色", "白色"}, Sizes: []string{"XL"}}
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(request, aimatching.SourceAI, "米白色", "XL", "在当次候选中唯一匹配")}
s := testService(db)
s.Matcher = matcher
task, err := createLive(t, s, f)
if err != nil || matcher.calls != 0 || task.SpecSource != "unresolved" {
t.Fatalf("task creation called matcher or skipped probe: task=%+v calls=%d err=%v", task, matcher.calls, err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || first.Phase != models.PurchaseAttemptPhaseSpecProbe {
t.Fatalf("probe start: %+v %v", first, err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["米白色","白色"]},{"key":"size","values":["XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || matcher.calls != 1 || resolved.Status != models.PurchaseTaskStatusPending || resolved.MappedColor != "米白色" || resolved.MappedSize != "XL" {
t.Fatalf("probe AI result: %+v calls=%d err=%v", resolved, matcher.calls, err)
}
}
func TestLiveProbeReusesConfirmedColorAndResolvesCurrentSizeWithoutAI(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
"target_color": "薑黃色",
"target_size": "5XL",
}).Error; err != nil {
t.Fatal(err)
}
specs := []shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "薑黃色", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "黄色", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "5XL", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "XXXXXL", Source: shopeeproduct.MappingSourceAIMatch, Status: shopeeproduct.MappingStatusConfirmed, Reason: "历史档案原文"}}}},
}
raw, _ := json.Marshal(specs)
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", string(raw)).Error; err != nil {
t.Fatal(err)
}
matcher := &liveProbeMatcher{err: errors.New("AI should not be called")}
s := testService(db)
s.Matcher = matcher
task, err := createLive(t, s, f)
if err != nil || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
t.Fatalf("live task skipped mandatory probe: %+v err=%v", task, err)
}
if err = db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", `[]`).Error; err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["白色","黄色","黑色"]},{"key":"size","values":["4XL","5XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || matcher.calls != 0 || resolved.Status != models.PurchaseTaskStatusPending {
t.Fatalf("confirmed mapping resolution failed: %+v calls=%d err=%v", resolved, matcher.calls, err)
}
if resolved.MappedColor != "黄色" || resolved.MappedSize != "5XL" {
t.Fatalf("task did not freeze current probe labels: %+v", resolved)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil {
t.Fatal(err)
}
var decision struct {
RoleSources map[string]string `json:"roleSources"`
}
if err = json.Unmarshal([]byte(saved.SpecDecisionSnapshot), &decision); err != nil {
t.Fatal(err)
}
if saved.SpecSource != "manual_mapping" || decision.RoleSources[shopeeproduct.RoleColor] != "manual_mapping" || decision.RoleSources[shopeeproduct.RoleSize] != aimatching.SourceExact {
t.Fatalf("mixed role sources were not audited: task=%+v decision=%+v", saved, decision)
}
}
func TestLiveProbeCallsAIOnlyForRoleWithoutCurrentConfirmedMapping(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "薑黃色").Error; err != nil {
t.Fatal(err)
}
specs := []shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "薑黃色", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "旧黄色", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "XL", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "XL", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
}
raw, _ := json.Marshal(specs)
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", string(raw)).Error; err != nil {
t.Fatal(err)
}
aiRequest := aimatching.MatchRequest{TargetColor: "薑黃色", Colors: []string{"黄色"}}
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(aiRequest, aimatching.SourceAI, "黄色", "", "当前颜色候选匹配")}
s := testService(db)
s.Matcher = matcher
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黄色"]},{"key":"size","values":["XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || matcher.calls != 1 || resolved.MappedColor != "黄色" || resolved.MappedSize != "XL" {
t.Fatalf("partial AI resolution failed: %+v request=%+v err=%v", resolved, matcher.request, err)
}
if matcher.request.TargetColor != "薑黃色" || matcher.request.TargetSize != "" || len(matcher.request.Colors) != 1 || len(matcher.request.Sizes) != 0 {
t.Fatalf("resolved size was unnecessarily sent to AI: %+v", matcher.request)
}
}
func TestLiveProbeRejectsMatcherValueOutsideCurrentCandidates(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙黑").Error; err != nil {
t.Fatal(err)
}
request := aimatching.MatchRequest{TargetColor: "象牙黑", Colors: []string{"黑色"}}
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(request, aimatching.SourceAI, "候选外颜色", "XL", "无效返回")}
s := testService(db)
s.Matcher = matcher
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || resolved.Status != models.PurchaseTaskStatusFailed || resolved.MappedColor != "" || resolved.MappedSize != "" {
t.Fatalf("out-of-candidate result was accepted: %+v err=%v", resolved, err)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil || saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_NOT_MATCHED" {
t.Fatalf("outside-candidate failure was not persisted: %+v err=%v", saved, err)
}
if saved.ErrorMessage == nil || *saved.ErrorMessage != "已采集到当前规格,但未能确定颜色或尺码映射" {
t.Fatalf("outside-candidate failure reason was not preserved: %+v", saved)
}
}
func TestLiveProbeUsesAccurateMessageWhenCompleteCandidatesCannotBeMatched(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "薑黃色").Error; err != nil {
t.Fatal(err)
}
s := testService(db)
s.Matcher = &liveProbeMatcher{err: &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "no match"}}
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黄色"]},{"key":"size","values":["XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || resolved.Status != models.PurchaseTaskStatusFailed {
t.Fatalf("no-match probe did not converge: %+v err=%v", resolved, err)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil {
t.Fatal(err)
}
if saved.ErrorMessage == nil || *saved.ErrorMessage != "已采集到当前规格,但未能确定颜色或尺码映射" {
t.Fatalf("complete candidates used misleading failure reason: %+v", saved)
}
}
func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
@@ -428,7 +672,7 @@ func TestSpecResolutionAllowedMatrix(t *testing.T) {
}
}
func TestCreateUsesDeterministicTraditionalAndWeightMatching(t *testing.T) {
func TestLiveProbeUsesDeterministicTraditionalAndWeightMatching(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"浅蓝色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"4XL 160-170斤","selectable":true}]}]`).Error; err != nil {
@@ -437,12 +681,26 @@ func TestCreateUsesDeterministicTraditionalAndWeightMatching(t *testing.T) {
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{"target_color": "淺藍色", "target_size": "4XL 80-85公斤"}).Error; err != nil {
t.Fatal(err)
}
task, err := createLive(t, testService(db), f)
if err != nil || task.SpecSource != "exact_match" || task.MappedColorSnapshot != "浅蓝色" || task.MappedSizeSnapshot != "4XL 160-170斤" {
t.Fatalf("deterministic match failed: %+v %v", task, err)
s := testService(db)
task, err := createLive(t, s, f)
if err != nil || task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
t.Fatalf("live task skipped probe: %+v %v", task, err)
}
if strings.Contains(task.SpecDecisionSnapshot, "apiKey") || !strings.Contains(task.SpecDecisionSnapshot, "exact_match") {
t.Fatalf("task decision snapshot is unsafe or incomplete: %s", task.SpecDecisionSnapshot)
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || first.Phase != models.PurchaseAttemptPhaseSpecProbe {
t.Fatalf("probe start: %+v %v", first, err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["浅蓝色"]},{"key":"size","values":["4XL 160-170斤"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || resolved.MappedColor != "浅蓝色" || resolved.MappedSize != "4XL 160-170斤" {
t.Fatalf("deterministic probe match failed: %+v %v", resolved, err)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil || strings.Contains(saved.SpecDecisionSnapshot, "apiKey") || !strings.Contains(saved.SpecDecisionSnapshot, "exact_match") {
t.Fatalf("task decision snapshot is unsafe or incomplete: %s err=%v", saved.SpecDecisionSnapshot, err)
}
}
@@ -457,12 +715,12 @@ func TestCreateDoesNotTrustMappedLabelsFromRequest(t *testing.T) {
SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MappedColor: "白色", MappedSize: "XL",
MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true),
})
if err != nil || task.SpecSource != "exact_match" || task.MappedColorSnapshot != "黑色" || task.MappedSizeSnapshot != "XL" {
t.Fatalf("request-provided mapping must not override server match: %+v %v", task, err)
if err != nil || task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
t.Fatalf("request-provided mapping must not skip live probe: %+v %v", task, err)
}
}
func TestCreateQueuesUsableArchiveWhenDeterministicMatchFails(t *testing.T) {
func TestCreateDoesNotQueueArchiveMatchingBeforeLiveProbe(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"L","selectable":true}]}]`).Error; err != nil {
@@ -475,12 +733,12 @@ func TestCreateQueuesUsableArchiveWhenDeterministicMatchFails(t *testing.T) {
t.Fatalf("task=%+v err=%v", task, err)
}
var count int64
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 1 {
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("work count=%d err=%v", count, err)
}
}
func TestCreateQueuesExternalMatchingWithoutCallingProvider(t *testing.T) {
func TestCreateDispatchesProbeInsteadOfExternalArchiveMatching(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000},{"name":"米白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
@@ -495,12 +753,48 @@ func TestCreateQueuesExternalMatchingWithoutCallingProvider(t *testing.T) {
if err != nil || task.ID == 0 || task.SpecSource != "unresolved" {
t.Fatalf("queued task=%+v err=%v", task, err)
}
var work models.PurchaseSpecMatchWorkItem
if err := db.Where("purchase_task_id = ?", task.ID).First(&work).Error; err != nil || work.Status != models.PurchaseMatchPending || work.InputFingerprint == "" {
t.Fatalf("work=%+v err=%v", work, err)
var count int64
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("unexpected archive match work count=%d err=%v", count, err)
}
if next, err := service.Next(context.Background(), f.token); err != nil || next != nil {
t.Fatalf("matching task dispatched: %+v %v", next, err)
if next, err := service.Next(context.Background(), f.token); err != nil || next == nil || next.MappedColor != "" || next.MappedSize != "" || !next.SpecResolutionAllowed {
t.Fatalf("probe task was not dispatchable: %+v %v", next, err)
}
}
func TestNextReturnsAssignedProbeWaitingTaskBeforeOtherWork(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1200}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
t.Fatal(err)
}
service := testService(db)
request := StockCreateRequest{
RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive,
PDDProductID: f.pdd.ID, DeviceID: &f.device.ID, Color: "黑色", Size: "XL",
Quantity: 1, MinUnitPriceCent: 900, MaxUnitPriceCent: 1500,
}
waiting, _, err := service.CreateStock(context.Background(), request)
if err != nil {
t.Fatal(err)
}
request.RequestID = uuid.NewString()
other, _, err := service.CreateStock(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if err = db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", waiting.ID).Updates(map[string]any{
"status": models.PurchaseTaskStatusSpecProbePending, "mapped_color_snapshot": "", "mapped_size_snapshot": "",
}).Error; err != nil {
t.Fatal(err)
}
next, err := service.Next(context.Background(), f.token)
if err != nil || next == nil || next.TaskID != waiting.ID || next.Status != models.PurchaseTaskStatusSpecProbePending {
t.Fatalf("waiting probe task not reserved: next=%+v other=%d err=%v", next, other.ID, err)
}
if _, err = service.Claim(context.Background(), other.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeDeviceBusy {
t.Fatalf("other purchase claimed while probe waits: %v", err)
}
}
@@ -509,9 +803,10 @@ func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) {
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, _ := createLive(t, s, f)
s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
started, _ := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
started := startLivePurchaseAfterProbe(t, s, f, task)
if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
actual := int64(1999)
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
@@ -527,7 +822,7 @@ func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) {
t.Fatalf("unknown task evidence not preserved: %+v", saved)
}
var attempt models.PurchaseTaskAttempt
if err := db.Where("task_id = ?", task.ID).First(&attempt).Error; err != nil {
if err := db.Where("task_id = ?", task.ID).Order("attempt_number DESC").First(&attempt).Error; err != nil {
t.Fatal(err)
}
if attempt.ErrorCode == nil || *attempt.ErrorCode != CodeOrderTimeMissing || attempt.ErrorMessage == nil {
@@ -544,9 +839,10 @@ func TestOrderUnknownRejectsOrderFieldsAndUnapprovedFailureCode(t *testing.T) {
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, _ := createLive(t, s, f)
s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
started, _ := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
started := startLivePurchaseAfterProbe(t, s, f, task)
if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
@@ -48,6 +48,9 @@ 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)
defer cancel()
requestID := strings.TrimSpace(request.RequestID)
if _, err := uuid.Parse(requestID); err != nil {
return AutoMatchResponse{}, invalidRequest("requestId 必须是 UUID")
@@ -0,0 +1,360 @@
package shopeeproduct
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"go-admin/app/goauto/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
SpecAutoMatchInvokeTarget = "GoAutoShopeeSpecAutoMatch"
defaultAutoMatchBatchLimit = 20
autoMatchLeaseDuration = 30 * time.Minute
autoMatchRetryDelay = time.Hour
maxAutoMatchAttempts = 3
)
type AutoMatchRunView struct {
models.ShopeeSpecAutoMatchRun
AlreadyRunning bool `json:"alreadyRunning,omitempty"`
Replayed bool `json:"replayed,omitempty"`
}
// StartAutoMatchRun acquires the single database-backed activity slot. A
// repeated requestId is idempotent; a concurrent trigger receives the current
// run instead of starting a second batch.
func (service *Service) StartAutoMatchRun(ctx context.Context, trigger, requestID string, requestedBy *uint64, batchLimit int) (AutoMatchRunView, bool, error) {
if _, err := uuid.Parse(strings.TrimSpace(requestID)); err != nil {
return AutoMatchRunView{}, false, invalidRequest("requestId 必须是 UUID")
}
if trigger != "manual" && trigger != "scheduled" {
return AutoMatchRunView{}, false, invalidRequest("trigger 无效")
}
if batchLimit <= 0 {
batchLimit = defaultAutoMatchBatchLimit
}
if batchLimit > 100 {
return AutoMatchRunView{}, false, invalidRequest("batchLimit 不能超过 100")
}
now := time.Now().UTC()
lease := now.Add(autoMatchLeaseDuration)
owner := uuid.NewString()
one := uint8(1)
var result models.ShopeeSpecAutoMatchRun
created := false
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.ShopeeSpecAutoMatchRun{}).
Where("status = ? AND active_slot = ? AND lease_expires_at < ?", "running", 1, now).
Updates(map[string]any{"status": "failed", "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "error_summary": "上次运行租约过期,已安全释放", "finished_at": now}).Error; err != nil {
return err
}
if err := tx.Where("request_id = ?", requestID).First(&result).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err := tx.Where("status = ? AND active_slot = ?", "running", 1).First(&result).Error; err == nil {
result.ActiveSlot = &one
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
result = models.ShopeeSpecAutoMatchRun{RequestID: requestID, Trigger: trigger, Status: "running", ActiveSlot: &one, LeaseOwner: owner, LeaseExpiresAt: &lease, RequestedBy: requestedBy, BatchLimit: batchLimit, StartedAt: now}
if err := tx.Create(&result).Error; err != nil {
return err
}
created = true
return nil
})
if err != nil {
// A unique-slot race means another instance won after our read. Return
// its run as the stable, non-error result.
if findErr := service.DB.WithContext(ctx).Where("status = ? AND active_slot = ?", "running", 1).First(&result).Error; findErr == nil {
return AutoMatchRunView{ShopeeSpecAutoMatchRun: result, AlreadyRunning: true}, false, nil
}
return AutoMatchRunView{}, false, internalError(err)
}
view := AutoMatchRunView{ShopeeSpecAutoMatchRun: result}
if !created {
view.AlreadyRunning = result.RequestID != requestID
view.Replayed = result.RequestID == requestID
}
return view, created, nil
}
func (service *Service) LatestAutoMatchRun(ctx context.Context) (*AutoMatchRunView, error) {
var run models.ShopeeSpecAutoMatchRun
err := service.DB.WithContext(ctx).Order("id DESC").First(&run).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, internalError(err)
}
return &AutoMatchRunView{ShopeeSpecAutoMatchRun: run}, nil
}
// ProcessAutoMatchRun performs a bounded batch. It is safe to call from an
// HTTP-launched goroutine or the scheduler because only the run owning the
// active slot may update and finish itself.
func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) error {
var run models.ShopeeSpecAutoMatchRun
if err := service.DB.WithContext(ctx).First(&run, runID).Error; err != nil {
return err
}
if run.Status != "running" || run.ActiveSlot == nil || *run.ActiveSlot != 1 {
return nil
}
limit := run.BatchLimit
if limit <= 0 || limit > 100 {
limit = defaultAutoMatchBatchLimit
}
var candidates []models.ShopeeProduct
queryLimit := limit * 25
if queryLimit < 100 {
queryLimit = 100
}
if queryLimit > 1000 {
queryLimit = 1000
}
if err := service.DB.WithContext(ctx).
Joins("JOIN pdd_product ON pdd_product.id = shopee_product.pdd_product_id AND pdd_product.status = ?", "active").
Where("shopee_product.pdd_product_id IS NOT NULL").
Order("shopee_product.updated_at ASC, shopee_product.id ASC").Limit(queryLimit).Find(&candidates).Error; err != nil {
service.finishAutoMatchRun(run, "failed", 0, 0, 0, 0, 0, 1, "扫描符合条件的商品失败")
return err
}
eligible, processed, confirmed, unmatched, failed := 0, 0, 0, 0, 0
firstError := ""
for _, product := range candidates {
if processed >= limit {
break
}
fingerprint, ok, err := service.autoMatchEligibility(ctx, product)
if err != nil {
failed++
if firstError == "" {
firstError = safeBatchError(err)
}
continue
}
if !ok {
continue
}
eligible++
work, claimed, err := service.claimAutoMatchWork(ctx, run, product.ID, fingerprint)
if err != nil {
failed++
if firstError == "" {
firstError = safeBatchError(err)
}
continue
}
if !claimed {
continue
}
processed++
service.renewAutoMatchRun(run)
response, matchErr := service.AutoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]})
// fingerprint begins with the 64-character context version.
postFingerprint := fingerprint
if next, _, nextErr := service.autoMatchEligibility(ctx, product); nextErr == nil && next != "" {
postFingerprint = next
}
if matchErr != nil {
failed++
if firstError == "" {
firstError = safeBatchError(matchErr)
}
service.completeAutoMatchWork(work, postFingerprint, 0, 0, matchErr)
continue
}
confirmed += response.ConfirmedCount
unmatched += response.UnmatchedCount
service.completeAutoMatchWork(work, postFingerprint, response.ConfirmedCount, response.UnmatchedCount, nil)
}
status := "completed"
if failed > 0 {
status = "completed_partial"
}
return service.finishAutoMatchRun(run, status, len(candidates), eligible, processed, confirmed, unmatched, failed, firstError)
}
func (service *Service) autoMatchEligibility(ctx context.Context, product models.ShopeeProduct) (string, bool, error) {
if product.PDDProductID == nil {
return "", false, nil
}
var pdd models.PDDProduct
if err := service.DB.WithContext(ctx).First(&pdd, *product.PDDProductID).Error; err != nil {
return "", false, err
}
if pdd.Status != "active" {
return "", false, nil
}
shopeeSpecs, err := Unmarshal(product.SpecsJSON)
if err != nil {
return "", false, err
}
shared, needsMatch := false, false
for _, role := range []string{RoleColor, RoleSize} {
pddValues, err := selectablePDDValues(pdd.SpecsJSON, role)
if err != nil {
return "", false, err
}
if len(pddValues) == 0 {
continue
}
for _, dimension := range shopeeSpecs {
if dimension.Role != role || len(dimension.Values) == 0 {
continue
}
shared = true
for _, value := range dimension.Values {
if value.Mapping == nil || value.Mapping.Status != MappingStatusConfirmed || !pddValues[value.Mapping.PDDValue] {
needsMatch = true
}
}
}
}
if !shared || !needsMatch {
return "", false, nil
}
contextVersion := computeSpecContextVersion(product.PDDProductID, product.SpecsJSON, pdd.SpecsJSON)
var setting struct{ UpdatedAt time.Time }
_ = service.DB.WithContext(ctx).Table((models.AIMatchingSetting{}).TableName()).Select("updated_at").Where("id = ?", 1).Scan(&setting).Error
h := sha256.Sum256([]byte(contextVersion + "\x00" + setting.UpdatedAt.UTC().Format(time.RFC3339Nano)))
// Keeping the context version as a prefix lets ProcessAutoMatchRun pass the
// exact version to #194 without re-reading a potentially drifting input.
return contextVersion + hex.EncodeToString(h[:]), true, nil
}
func (service *Service) claimAutoMatchWork(ctx context.Context, run models.ShopeeSpecAutoMatchRun, productID uint64, fingerprint string) (models.ShopeeSpecAutoMatchWorkItem, bool, error) {
now := time.Now().UTC()
lease := now.Add(autoMatchLeaseDuration)
var work models.ShopeeSpecAutoMatchWorkItem
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Where("shopee_product_id = ?", productID).First(&work).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
work = models.ShopeeSpecAutoMatchWorkItem{ShopeeProductID: productID, RunID: &run.ID, InputFingerprint: fingerprint, Status: "running", AttemptCount: 1, LeaseOwner: run.LeaseOwner, LeaseExpiresAt: &lease}
return tx.Create(&work).Error
}
if err != nil {
return err
}
if work.InputFingerprint == fingerprint {
if work.Status == "completed" || work.Status == "unmatched" || work.AttemptCount >= maxAutoMatchAttempts || (work.NextAttemptAt != nil && work.NextAttemptAt.After(now)) || (work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now)) {
return errWorkNotClaimed
}
} else {
work.AttemptCount = 0
}
updates := map[string]any{"run_id": run.ID, "input_fingerprint": fingerprint, "status": "running", "attempt_count": work.AttemptCount + 1, "next_attempt_at": nil, "lease_owner": run.LeaseOwner, "lease_expires_at": lease, "last_error_code": "", "last_error": ""}
if err := tx.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error; err != nil {
return err
}
return tx.First(&work, work.ID).Error
})
if errors.Is(err, errWorkNotClaimed) {
return work, false, nil
}
return work, err == nil, err
}
var errWorkNotClaimed = errors.New("auto match work not claimed")
func (service *Service) completeAutoMatchWork(work models.ShopeeSpecAutoMatchWorkItem, fingerprint string, confirmed, unmatched int, matchErr error) {
now := time.Now().UTC()
updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil, "confirmed_count": confirmed, "unmatched_count": unmatched}
if matchErr == nil {
if unmatched > 0 {
updates["status"] = "unmatched"
} else {
updates["status"] = "completed"
}
updates["next_attempt_at"], updates["last_error_code"], updates["last_error"] = nil, "", ""
} else {
code := batchErrorCode(matchErr)
updates["status"], updates["last_error_code"], updates["last_error"] = "failed", code, safeBatchError(matchErr)
if code == CodeAIUnavailable && work.AttemptCount < maxAutoMatchAttempts {
next := now.Add(autoMatchRetryDelay)
updates["next_attempt_at"] = next
} else {
updates["next_attempt_at"] = nil
}
}
_ = service.DB.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error
}
func (service *Service) renewAutoMatchRun(run models.ShopeeSpecAutoMatchRun) {
lease := time.Now().UTC().Add(autoMatchLeaseDuration)
_ = service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Update("lease_expires_at", lease).Error
}
func (service *Service) finishAutoMatchRun(run models.ShopeeSpecAutoMatchRun, status string, scanned, eligible, processed, confirmed, unmatched, failed int, summary string) error {
now := time.Now().UTC()
updates := map[string]any{"status": status, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "scanned_count": scanned, "eligible_count": eligible, "processed_count": processed, "confirmed_count": confirmed, "unmatched_count": unmatched, "failed_count": failed, "error_summary": truncateBatchText(summary), "finished_at": now}
return service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Updates(updates).Error
}
func batchErrorCode(err error) string {
var serviceErr *ServiceError
if errors.As(err, &serviceErr) {
return serviceErr.Code
}
return CodeInternal
}
func safeBatchError(err error) string {
var serviceErr *ServiceError
if errors.As(err, &serviceErr) {
return truncateBatchText(serviceErr.Message)
}
return "服务端处理失败"
}
func truncateBatchText(value string) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) > 500 {
runes = runes[:500]
}
return string(runes)
}
type scheduledAutoMatchArgs struct {
BatchLimit int `json:"batchLimit"`
}
type ScheduledAutoMatchJob struct{}
func (ScheduledAutoMatchJob) Exec(_ interface{}) error {
return errors.New("规格自动匹配定时任务缺少数据库连接")
}
func (ScheduledAutoMatchJob) ExecWithDB(db *gorm.DB, arg interface{}) error {
args := scheduledAutoMatchArgs{BatchLimit: defaultAutoMatchBatchLimit}
if raw, ok := arg.(string); ok && strings.TrimSpace(raw) != "" {
if err := json.Unmarshal([]byte(raw), &args); err != nil {
return fmt.Errorf("规格自动匹配参数不是合法 JSON: %w", err)
}
}
if args.BatchLimit < 1 || args.BatchLimit > 100 {
return errors.New("规格自动匹配 batchLimit 必须在 1 到 100 之间")
}
service := NewService(db)
run, created, err := service.StartAutoMatchRun(context.Background(), "scheduled", uuid.NewString(), nil, args.BatchLimit)
if err != nil || !created {
return err
}
return service.ProcessAutoMatchRun(context.Background(), run.ID)
}
@@ -0,0 +1,86 @@
package shopeeproduct
import (
"context"
"testing"
"go-admin/app/goauto/models"
"github.com/google/uuid"
)
func TestAutoMatchRunIsIdempotentAndGloballySerialized(t *testing.T) {
db := openTestDB(t)
service := NewService(db)
requestID := uuid.NewString()
first, created, err := service.StartAutoMatchRun(context.Background(), "manual", requestID, nil, 20)
if err != nil || !created {
t.Fatalf("first=%+v created=%v err=%v", first, created, err)
}
replay, created, err := service.StartAutoMatchRun(context.Background(), "manual", requestID, nil, 20)
if err != nil || created || !replay.Replayed || replay.ID != first.ID {
t.Fatalf("replay=%+v created=%v err=%v", replay, created, err)
}
concurrent, created, err := service.StartAutoMatchRun(context.Background(), "scheduled", uuid.NewString(), nil, 20)
if err != nil || created || !concurrent.AlreadyRunning || concurrent.ID != first.ID {
t.Fatalf("concurrent=%+v created=%v err=%v", concurrent, created, err)
}
}
func TestProcessAutoMatchRunConfirmsExactSizeAndFinishes(t *testing.T) {
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
service := NewService(db)
createdProduct, err := service.Create(context.Background(), CreateRequest{
RequestID: uuid.NewString(), ShopeeItemID: "SP-BATCH-EXACT", PDDProductID: &pdd.ID,
Specs: []SpecDimension{{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: " xl ", Source: ValueSourceImport}}}},
})
if err != nil {
t.Fatal(err)
}
run, started, err := service.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil || !started {
t.Fatalf("run=%+v started=%v err=%v", run, started, err)
}
if err := service.ProcessAutoMatchRun(context.Background(), run.ID); err != nil {
t.Fatal(err)
}
latest, err := service.LatestAutoMatchRun(context.Background())
if err != nil {
t.Fatal(err)
}
if latest == nil || latest.Status != "completed" || latest.ProcessedCount != 1 || latest.ConfirmedCount != 1 || latest.ActiveSlot != nil {
t.Fatalf("latest=%+v", latest)
}
detail, err := service.Detail(context.Background(), createdProduct.Product.ID)
if err != nil {
t.Fatal(err)
}
mapping := detail.Product.Specs[0].Values[0].Mapping
if mapping == nil || mapping.Status != MappingStatusConfirmed || mapping.PDDValue != "XL" {
t.Fatalf("mapping=%+v", mapping)
}
}
func TestUnchangedUnmatchedWorkIsNotClaimedAgain(t *testing.T) {
db := openTestDB(t)
service := NewService(db)
one := uint8(1)
run := models.ShopeeSpecAutoMatchRun{RequestID: uuid.NewString(), Trigger: "manual", Status: "running", ActiveSlot: &one, LeaseOwner: uuid.NewString(), BatchLimit: 20}
if err := db.Create(&run).Error; err != nil {
t.Fatal(err)
}
work, claimed, err := service.claimAutoMatchWork(context.Background(), run, 99, "fingerprint")
if err != nil || !claimed {
t.Fatalf("work=%+v claimed=%v err=%v", work, claimed, err)
}
service.completeAutoMatchWork(work, "fingerprint", 0, 1, nil)
_, claimed, err = service.claimAutoMatchWork(context.Background(), run, 99, "fingerprint")
if err != nil || claimed {
t.Fatalf("unchanged unmatched claimed=%v err=%v", claimed, err)
}
_, claimed, err = service.claimAutoMatchWork(context.Background(), run, 99, "changed")
if err != nil || !claimed {
t.Fatalf("changed input claimed=%v err=%v", claimed, err)
}
}
@@ -5,11 +5,13 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
@@ -116,6 +118,20 @@ func TestAutoMatchMappingsProviderFailureDoesNotWrite(t *testing.T) {
}
}
func TestWriteErrorMapsAIUnavailableToStructured503(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
writeError(context, aiUnavailable("AI 匹配服务暂时不可用,请稍后重试"))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), `"code":"AI_MATCHING_UNAVAILABLE"`) {
t.Fatalf("response is not structured: %s", recorder.Body.String())
}
}
func TestAutoMatchMappingsRejectsContextDriftBeforeAtomicWrite(t *testing.T) {
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
@@ -1,6 +1,7 @@
package shopeeproduct
import (
"context"
"encoding/json"
"errors"
"io"
@@ -300,6 +301,45 @@ func (handler Handler) AutoMatchMappings(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
}
func (handler Handler) StartAutoMatchRun(c *gin.Context) {
var request struct {
RequestID string `json:"requestId"`
}
if err := decodeJSON(c, &request); err != nil {
writeError(c, invalidRequest("请求 JSON 无效"))
return
}
service, ok := handler.service(c)
if !ok {
return
}
operator := currentUserID(c)
run, created, err := service.StartAutoMatchRun(c.Request.Context(), "manual", request.RequestID, &operator, defaultAutoMatchBatchLimit)
if err != nil {
writeError(c, err)
return
}
if created {
go func(runID uint64, db *gorm.DB) {
_ = NewService(db).ProcessAutoMatchRun(context.Background(), runID)
}(run.ID, service.DB)
}
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"run": run}})
}
func (handler Handler) LatestAutoMatchRun(c *gin.Context) {
service, ok := handler.service(c)
if !ok {
return
}
run, err := service.LatestAutoMatchRun(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"run": run}})
}
func (handler Handler) BatchDelete(c *gin.Context) {
var request BatchDeleteRequest
if err := decodeJSON(c, &request); err != nil {
@@ -10,6 +10,9 @@ import (
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
handler := Handler{}
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)
adminOnlyRuns.GET("/latest", handler.LatestAutoMatchRun)
admin.GET("", handler.List)
admin.POST("", handler.Create)
admin.POST("/batch-delete", handler.BatchDelete)
@@ -0,0 +1,489 @@
package sybimport
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
SpecAIParseInvokeTarget = "GoAutoSYBSpecAIParse"
defaultAIParseBatchLimit = 20
aiParseLeaseDuration = 30 * time.Minute
aiParseRetryDelay = time.Hour
maxAIParseAttempts = 3
)
var (
errAIParseWorkNotClaimed = errors.New("syb spec ai parse work not claimed")
errAIParseInputChanged = errors.New("syb spec ai parse input changed")
)
type aiParseInput struct {
ProductSpec string
Colors []string
Sizes []string
Fingerprint string
}
func StartSpecAIParseRun(ctx context.Context, db *gorm.DB, requestID string, batchLimit int) (models.SYBSpecAIParseRun, bool, error) {
if _, err := uuid.Parse(strings.TrimSpace(requestID)); err != nil {
return models.SYBSpecAIParseRun{}, false, fmt.Errorf("requestId 必须是 UUID")
}
if batchLimit <= 0 {
batchLimit = defaultAIParseBatchLimit
}
if batchLimit > 100 {
return models.SYBSpecAIParseRun{}, false, fmt.Errorf("batchLimit 不能超过 100")
}
now := time.Now().UTC()
lease := now.Add(aiParseLeaseDuration)
one := uint8(1)
owner := uuid.NewString()
var run models.SYBSpecAIParseRun
created := false
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.SYBSpecAIParseRun{}).
Where("status = ? AND active_slot = ? AND lease_expires_at < ?", "running", 1, now).
Updates(map[string]any{"status": "failed", "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "error_summary": "上次运行租约过期,已安全释放", "finished_at": now}).Error; err != nil {
return err
}
if err := tx.Where("request_id = ?", requestID).First(&run).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err := tx.Where("status = ? AND active_slot = ?", "running", 1).First(&run).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
run = models.SYBSpecAIParseRun{
RequestID: requestID, Trigger: "scheduled", Status: "running", ActiveSlot: &one,
LeaseOwner: owner, LeaseExpiresAt: &lease, BatchLimit: batchLimit, StartedAt: now,
}
if err := tx.Create(&run).Error; err != nil {
return err
}
created = true
return nil
})
if err != nil {
if findErr := db.WithContext(ctx).Where("status = ? AND active_slot = ?", "running", 1).First(&run).Error; findErr == nil {
return run, false, nil
}
return models.SYBSpecAIParseRun{}, false, err
}
return run, created, nil
}
func ProcessSpecAIParseRun(ctx context.Context, db *gorm.DB, runID uint64) error {
var run models.SYBSpecAIParseRun
if err := db.WithContext(ctx).First(&run, runID).Error; err != nil {
return err
}
if run.Status != "running" || run.ActiveSlot == nil || *run.ActiveSlot != 1 {
return nil
}
limit := run.BatchLimit
if limit <= 0 || limit > 100 {
limit = defaultAIParseBatchLimit
}
queryLimit := limit * 25
if queryLimit < 100 {
queryLimit = 100
}
if queryLimit > 1000 {
queryLimit = 1000
}
var candidates []models.SYBProduct
if err := db.WithContext(ctx).
Where("manually_confirmed = ?", false).
Where("parse_status IN ?", []string{models.SYBParseStatusUncertain, models.SYBParseStatusFailed}).
Order("updated_at ASC, id ASC").Limit(queryLimit).Find(&candidates).Error; err != nil {
finishSpecAIParseRun(db, run, "failed", 0, 0, 0, 0, 0, 1, "扫描异常规格失败")
return err
}
eligible, processed, confirmed, unmatched, failed := 0, 0, 0, 0, 0
firstError := ""
for _, candidate := range candidates {
if processed >= limit {
break
}
if candidate.AIConfirmed {
current, currentErr := aiConfirmationTargetsCurrent(ctx, db, candidate)
if currentErr != nil {
failed++
continue
}
if current {
continue
}
if err := db.WithContext(ctx).Model(&models.SYBProduct{}).
Where("id = ? AND manually_confirmed = ?", candidate.ID, false).
Updates(map[string]any{"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "", "ai_confirmed_at": nil, "ai_input_fingerprint": ""}).Error; err != nil {
failed++
continue
}
candidate.AIConfirmed = false
}
outcome, err := Reparse(ctx, db, candidate.ID, false)
if err != nil {
failed++
if firstError == "" {
firstError = "确定性重新解析失败"
}
continue
}
if outcome.NewStatus == models.SYBParseStatusSuccess {
processed++
confirmed++
continue
}
if err := db.WithContext(ctx).First(&candidate, candidate.ID).Error; err != nil {
failed++
continue
}
input, ok, err := buildAIParseInput(ctx, db, candidate)
if err != nil {
failed++
if firstError == "" {
firstError = "读取 AI 解析上下文失败"
}
continue
}
if !ok {
continue
}
eligible++
work, claimed, err := claimSpecAIParseWork(ctx, db, run, candidate.ID, input.Fingerprint)
if err != nil {
failed++
continue
}
if !claimed {
continue
}
processed++
renewSpecAIParseRun(db, run)
matcher := aimatching.NewService(db)
result, matchErr := matcher.ResolveSYBSpec(ctx, aimatching.SYBSpecParseRequest{
ProductSpec: input.ProductSpec, Colors: input.Colors, Sizes: input.Sizes,
})
if matchErr == nil {
settings, settingsErr := matcher.Settings(ctx)
if settingsErr != nil {
matchErr = settingsErr
} else if result.Confidence == nil || *result.Confidence < settings.AutoConfirmMinConfidence || strings.TrimSpace(result.Reason) == "" {
unmatched++
completeSpecAIParseWork(db, work, input.Fingerprint, false, nil)
continue
} else {
matchErr = applyAIParseResult(ctx, db, candidate.ID, input.Fingerprint, result)
}
}
if matchErr != nil {
if isNoAIParseMatch(matchErr) || errors.Is(matchErr, errAIParseInputChanged) {
unmatched++
completeSpecAIParseWork(db, work, input.Fingerprint, false, nil)
continue
}
failed++
if firstError == "" {
firstError = safeAIParseError(matchErr)
}
completeSpecAIParseWork(db, work, input.Fingerprint, false, matchErr)
continue
}
confirmed++
completeSpecAIParseWork(db, work, input.Fingerprint, true, nil)
}
status := "completed"
if failed > 0 {
status = "completed_partial"
}
return finishSpecAIParseRun(db, run, status, len(candidates), eligible, processed, confirmed, unmatched, failed, firstError)
}
func buildAIParseInput(ctx context.Context, db *gorm.DB, record models.SYBProduct) (aiParseInput, bool, error) {
if record.ManuallyConfirmed || (record.ParseStatus != models.SYBParseStatusUncertain && record.ParseStatus != models.SYBParseStatusFailed) || record.ShopeeProductID == nil {
return aiParseInput{}, false, nil
}
var raw rawDetailSpec
if err := json.Unmarshal([]byte(record.RawJSON), &raw); err != nil {
return aiParseInput{}, false, err
}
raw.ProductSpec = strings.TrimSpace(raw.ProductSpec)
if raw.ProductSpec == "" {
return aiParseInput{}, false, nil
}
var product models.ShopeeProduct
if err := db.WithContext(ctx).First(&product, *record.ShopeeProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return aiParseInput{}, false, nil
}
return aiParseInput{}, false, err
}
specs, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
return aiParseInput{}, false, err
}
colors, sizes, ambiguous := closedShopeeCandidates(specs)
if ambiguous || (len(colors) == 0 && len(sizes) == 0) {
return aiParseInput{}, false, nil
}
var setting models.AIMatchingSetting
if err := db.WithContext(ctx).First(&setting, 1).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return aiParseInput{}, false, nil
}
return aiParseInput{}, false, err
}
if !setting.Enabled || strings.TrimSpace(setting.APIKey) == "" {
return aiParseInput{}, false, nil
}
fingerprintPayload := struct {
ProductSpec string
ShopeeProductID uint64
ShopeeSpecsJSON string
SettingUpdatedAt string
}{raw.ProductSpec, product.ID, product.SpecsJSON, setting.UpdatedAt.UTC().Format(time.RFC3339Nano)}
encoded, _ := json.Marshal(fingerprintPayload)
hash := sha256.Sum256(encoded)
return aiParseInput{ProductSpec: raw.ProductSpec, Colors: colors, Sizes: sizes, Fingerprint: hex.EncodeToString(hash[:])}, true, nil
}
func aiConfirmationTargetsCurrent(ctx context.Context, db *gorm.DB, record models.SYBProduct) (bool, error) {
if !record.AIConfirmed || record.ShopeeProductID == nil {
return false, nil
}
var product models.ShopeeProduct
if err := db.WithContext(ctx).First(&product, *record.ShopeeProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
specs, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
return false, err
}
colors, sizes, ambiguous := closedShopeeCandidates(specs)
if ambiguous {
return false, nil
}
return closedCandidateContains(record.TargetColor, colors) && closedCandidateContains(record.TargetSize, sizes) &&
(strings.TrimSpace(record.TargetColor) != "" || strings.TrimSpace(record.TargetSize) != ""), nil
}
func closedCandidateContains(value string, candidates []string) bool {
value = strings.TrimSpace(value)
if len(candidates) == 0 {
return value == ""
}
for _, candidate := range candidates {
if candidate == value {
return true
}
}
return false
}
func closedShopeeCandidates(specs []shopeeproduct.SpecDimension) (colors, sizes []string, ambiguous bool) {
roleDimensions := map[string]int{}
for _, dimension := range specs {
if dimension.Role != shopeeproduct.RoleColor && dimension.Role != shopeeproduct.RoleSize {
continue
}
values := make([]string, 0, len(dimension.Values))
seen := map[string]bool{}
for _, value := range dimension.Values {
name := strings.TrimSpace(value.Name)
if name != "" && !seen[name] {
seen[name] = true
values = append(values, name)
}
}
if len(values) == 0 {
continue
}
roleDimensions[dimension.Role]++
if roleDimensions[dimension.Role] > 1 {
return nil, nil, true
}
if dimension.Role == shopeeproduct.RoleColor {
colors = values
} else {
sizes = values
}
}
return colors, sizes, false
}
func applyAIParseResult(ctx context.Context, db *gorm.DB, id uint64, fingerprint string, result aimatching.SYBSpecParseResult) error {
now := time.Now().UTC()
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var record models.SYBProduct
if err := tx.First(&record, id).Error; err != nil {
return err
}
input, ok, err := buildAIParseInput(ctx, tx, record)
if err != nil {
return err
}
if !ok || input.Fingerprint != fingerprint {
return errAIParseInputChanged
}
if result.Confidence == nil || strings.TrimSpace(result.Reason) == "" {
return errAIParseInputChanged
}
write := tx.Model(&models.SYBProduct{}).Where("id = ? AND manually_confirmed = ? AND ai_confirmed = ?", id, false, false).Updates(map[string]any{
"target_color": result.Color, "target_size": result.Size,
"ai_confirmed": true, "ai_confidence": *result.Confidence, "ai_reason": truncateAIParseText(result.Reason),
"ai_confirmed_at": now, "ai_input_fingerprint": fingerprint,
})
if write.Error != nil {
return write.Error
}
if write.RowsAffected != 1 {
return errAIParseInputChanged
}
return mergeParsedSpec(tx, *record.ShopeeProductID, ParseResult{Color: result.Color, Size: result.Size, Status: models.SYBParseStatusSuccess})
})
}
func claimSpecAIParseWork(ctx context.Context, db *gorm.DB, run models.SYBSpecAIParseRun, productID uint64, fingerprint string) (models.SYBSpecAIParseWorkItem, bool, error) {
now := time.Now().UTC()
lease := now.Add(aiParseLeaseDuration)
var work models.SYBSpecAIParseWorkItem
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Where("syb_product_id = ?", productID).First(&work).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
work = models.SYBSpecAIParseWorkItem{SYBProductID: productID, RunID: &run.ID, InputFingerprint: fingerprint, Status: "running", AttemptCount: 1, LeaseOwner: run.LeaseOwner, LeaseExpiresAt: &lease}
return tx.Create(&work).Error
}
if err != nil {
return err
}
if work.InputFingerprint == fingerprint {
if work.Status == "completed" || work.Status == "unmatched" || work.AttemptCount >= maxAIParseAttempts || (work.NextAttemptAt != nil && work.NextAttemptAt.After(now)) || (work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now)) {
return errAIParseWorkNotClaimed
}
} else {
work.AttemptCount = 0
}
updates := map[string]any{"run_id": run.ID, "input_fingerprint": fingerprint, "status": "running", "attempt_count": work.AttemptCount + 1, "next_attempt_at": nil, "lease_owner": run.LeaseOwner, "lease_expires_at": lease, "last_error_code": "", "last_error": ""}
if err := tx.Model(&models.SYBSpecAIParseWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error; err != nil {
return err
}
return tx.First(&work, work.ID).Error
})
if errors.Is(err, errAIParseWorkNotClaimed) {
return work, false, nil
}
return work, err == nil, err
}
func completeSpecAIParseWork(db *gorm.DB, work models.SYBSpecAIParseWorkItem, fingerprint string, confirmed bool, parseErr error) {
now := time.Now().UTC()
updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil}
if parseErr == nil {
if confirmed {
updates["status"] = "completed"
} else {
updates["status"] = "unmatched"
}
updates["next_attempt_at"], updates["last_error_code"], updates["last_error"] = nil, "", ""
} else {
code := aiParseErrorCode(parseErr)
updates["status"], updates["last_error_code"], updates["last_error"] = "failed", code, safeAIParseError(parseErr)
if code == aimatching.CodeProviderUnavailable && work.AttemptCount < maxAIParseAttempts {
next := now.Add(aiParseRetryDelay)
updates["next_attempt_at"] = next
} else {
updates["next_attempt_at"] = nil
}
}
_ = db.Model(&models.SYBSpecAIParseWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error
}
func renewSpecAIParseRun(db *gorm.DB, run models.SYBSpecAIParseRun) {
lease := time.Now().UTC().Add(aiParseLeaseDuration)
_ = db.Model(&models.SYBSpecAIParseRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Update("lease_expires_at", lease).Error
}
func finishSpecAIParseRun(db *gorm.DB, run models.SYBSpecAIParseRun, status string, scanned, eligible, processed, confirmed, unmatched, failed int, summary string) error {
now := time.Now().UTC()
return db.Model(&models.SYBSpecAIParseRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Updates(map[string]any{
"status": status, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil,
"scanned_count": scanned, "eligible_count": eligible, "processed_count": processed,
"confirmed_count": confirmed, "unmatched_count": unmatched, "failed_count": failed,
"error_summary": truncateAIParseText(summary), "finished_at": now,
}).Error
}
func isNoAIParseMatch(err error) bool { return aiParseErrorCode(err) == aimatching.CodeNoMatch }
func aiParseErrorCode(err error) string {
var target *aimatching.Error
if errors.As(err, &target) {
return target.Code
}
return CodeInternal
}
func safeAIParseError(err error) string {
var target *aimatching.Error
if errors.As(err, &target) {
return truncateAIParseText(target.Message)
}
return "服务端处理失败"
}
func truncateAIParseText(value string) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) > 500 {
runes = runes[:500]
}
return string(runes)
}
type scheduledSpecAIParseArgs struct {
BatchLimit int `json:"batchLimit"`
}
type ScheduledSpecAIParseJob struct{}
func (ScheduledSpecAIParseJob) Exec(_ interface{}) error {
return errors.New("SYB 规格 AI 解析定时任务缺少数据库连接")
}
func (ScheduledSpecAIParseJob) ExecWithDB(db *gorm.DB, arg interface{}) error {
args := scheduledSpecAIParseArgs{BatchLimit: defaultAIParseBatchLimit}
if raw, ok := arg.(string); ok && strings.TrimSpace(raw) != "" {
if err := json.Unmarshal([]byte(raw), &args); err != nil {
return fmt.Errorf("SYB 规格 AI 解析参数不是合法 JSON: %w", err)
}
}
if args.BatchLimit < 1 || args.BatchLimit > 100 {
return errors.New("SYB 规格 AI 解析 batchLimit 必须在 1 到 100 之间")
}
run, created, err := StartSpecAIParseRun(context.Background(), db, uuid.NewString(), args.BatchLimit)
if err != nil || !created {
return err
}
return ProcessSpecAIParseRun(context.Background(), db, run.ID)
}
@@ -0,0 +1,204 @@
package sybimport_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"go-admin/app/goauto/sybimport"
"github.com/google/uuid"
"gorm.io/gorm"
)
func seedAIParseCandidate(t *testing.T, db *gorm.DB, serverURL string, detailID uint64, productSpec string) models.SYBProduct {
t.Helper()
detail := realDetailA()
detail.ID = detailID
detail.ProductSpec = productSpec
detail.Raw = []byte(fmt.Sprintf(`{"id":%d,"productSpec":%q}`, detail.ID, productSpec))
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), detail)
if err != nil {
t.Fatal(err)
}
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport}, {Name: "白色", Source: shopeeproduct.ValueSourceImport}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "L", Source: shopeeproduct.ValueSourceImport}, {Name: "XL", Source: shopeeproduct.ValueSourceImport}}},
})
if err != nil {
t.Fatal(err)
}
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", *applied.SYBProduct.ShopeeProductID).Update("specs_json", specs).Error; err != nil {
t.Fatal(err)
}
setting := models.AIMatchingSetting{ID: 1, Enabled: true, Provider: "openai_compatible", BaseURL: serverURL, Model: "test-model", APIKey: "test-key", TimeoutSeconds: 5, AutoConfirmMinConfidence: 0.9}
if err := db.Save(&setting).Error; err != nil {
t.Fatal(err)
}
return applied.SYBProduct
}
func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write([]byte(`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"原文对应唯一候选\",\"confidence\":0.95}"}}]}`))
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑色 XL")
rawBefore := record.RawJSON
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("start run: created=%v err=%v", created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
if !record.AIConfirmed || record.ManuallyConfirmed || record.ParseStatus != models.SYBParseStatusUncertain || record.TargetColor != "黑色" || record.TargetSize != "XL" || record.AIConfidence == nil || *record.AIConfidence != 0.95 || record.AIReason == "" || record.AIInputFingerprint == "" {
t.Fatalf("unexpected confirmed record: %+v", record)
}
if record.RawJSON != rawBefore {
t.Fatal("AI confirmation must not rewrite RawJSON")
}
var finished models.SYBSpecAIParseRun
if err := db.First(&finished, run.ID).Error; err != nil {
t.Fatal(err)
}
if finished.Status != "completed" || finished.ConfirmedCount != 1 || finished.ProcessedCount != 1 {
t.Fatalf("unexpected run: %+v", finished)
}
second, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("second start: created=%v err=%v", created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, second.ID); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 {
t.Fatalf("unchanged confirmed input called provider %d times", calls.Load())
}
}
func TestScheduledAIParseLeavesLowConfidenceUnmatchedForSameFingerprint(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
_, _ = response.Write([]byte(`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"仍有歧义\",\"confidence\":0.4}"}}]}`))
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19802, "黑色 XL")
for i := 0; i < 2; i++ {
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("start %d: created=%v err=%v", i, created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
}
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
if record.AIConfirmed || calls.Load() != 1 {
t.Fatalf("low confidence must remain unconfirmed and not repeat: confirmed=%v calls=%d", record.AIConfirmed, calls.Load())
}
var work models.SYBSpecAIParseWorkItem
if err := db.Where("syb_product_id = ?", record.ID).First(&work).Error; err != nil {
t.Fatal(err)
}
if work.Status != "unmatched" {
t.Fatalf("work status=%s", work.Status)
}
}
func TestScheduledAIParseSkipsEmptySourceAndManualConfirmation(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
response.WriteHeader(http.StatusInternalServerError)
}))
defer provider.Close()
db := openTestDB(t)
empty := seedAIParseCandidate(t, db, provider.URL, 19803, "")
manual := seedAIParseCandidate(t, db, provider.URL, 19804, "黑色 XL")
if _, err := sybimport.ManualCorrect(context.Background(), db, manual.ID, "黑色", "XL"); err != nil {
t.Fatal(err)
}
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("start: created=%v err=%v", created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
if calls.Load() != 0 {
t.Fatalf("ineligible rows called provider %d times", calls.Load())
}
var workCount int64
if err := db.Model(&models.SYBSpecAIParseWorkItem{}).Where("syb_product_id IN ?", []uint64{empty.ID, manual.ID}).Count(&workCount).Error; err != nil {
t.Fatal(err)
}
if workCount != 0 {
t.Fatalf("ineligible rows created %d work items", workCount)
}
}
func TestSpecAIParseRunHasSingleGlobalActiveSlot(t *testing.T) {
db := openTestDB(t)
first, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("first: created=%v err=%v", created, err)
}
second, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || created || second.ID != first.ID {
t.Fatalf("second must reuse active run: first=%d second=%d created=%v err=%v", first.ID, second.ID, created, err)
}
}
func TestScheduledAIParseRetriesProviderFailureAtMostThreeTimes(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
response.WriteHeader(http.StatusBadGateway)
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19805, "黑色 XL")
for attempt := 1; attempt <= 4; attempt++ {
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("start %d: created=%v err=%v", attempt, created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
if attempt < 3 {
past := time.Now().UTC().Add(-time.Minute)
if err := db.Model(&models.SYBSpecAIParseWorkItem{}).Where("syb_product_id = ?", record.ID).Update("next_attempt_at", past).Error; err != nil {
t.Fatal(err)
}
}
}
if calls.Load() != 3 {
t.Fatalf("provider calls=%d, want 3", calls.Load())
}
var work models.SYBSpecAIParseWorkItem
if err := db.Where("syb_product_id = ?", record.ID).First(&work).Error; err != nil {
t.Fatal(err)
}
if work.AttemptCount != 3 || work.NextAttemptAt != nil {
t.Fatalf("retry state=%+v", work)
}
}
+33 -2
View File
@@ -130,13 +130,37 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
result.Outcome = OutcomeCreated
case err == nil:
record.ID = existing.ID
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", existing.ID).Updates(map[string]any{
// Human-confirmed target values are authoritative and survive every
// source re-import. ParseStatus/ParseNote below still record what the
// current deterministic parser observed for audit.
if existing.ManuallyConfirmed {
record.TargetColor, record.TargetSize = existing.TargetColor, existing.TargetSize
record.ManuallyConfirmed = true
}
updates := map[string]any{
"stock_id": record.StockID, "shop_name": record.ShopName, "shopee_item_id": record.ShopeeItemID,
"shopee_product_id": record.ShopeeProductID, "product_title": record.ProductTitle,
"target_color": record.TargetColor, "target_size": record.TargetSize,
"quantity": record.Quantity, "unit_price_cent": record.UnitPriceCent, "image_url": record.ImageURL,
"parse_status": record.ParseStatus, "parse_note": record.ParseNote, "raw_json": record.RawJSON,
}).Error; err != nil {
}
// An identical re-import keeps a valid AI decision. Changed source,
// link, or a newly deterministic parse invalidates it atomically.
preserveAI := existing.AIConfirmed && !existing.ManuallyConfirmed && parsed.Status != models.SYBParseStatusSuccess &&
existing.RawJSON == record.RawJSON && sameOptionalID(existing.ShopeeProductID, record.ShopeeProductID)
if preserveAI {
record.TargetColor, record.TargetSize = existing.TargetColor, existing.TargetSize
updates["target_color"], updates["target_size"] = existing.TargetColor, existing.TargetSize
record.AIConfirmed, record.AIConfidence, record.AIReason = true, existing.AIConfidence, existing.AIReason
record.AIConfirmedAt, record.AIInputFingerprint = existing.AIConfirmedAt, existing.AIInputFingerprint
} else {
updates["ai_confirmed"], updates["ai_confidence"], updates["ai_reason"] = false, nil, ""
updates["ai_confirmed_at"], updates["ai_input_fingerprint"] = nil, ""
}
if existing.ManuallyConfirmed {
updates["target_color"], updates["target_size"] = existing.TargetColor, existing.TargetSize
}
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", existing.ID).Updates(updates).Error; err != nil {
return err
}
result.Outcome = OutcomeUpdated
@@ -161,6 +185,13 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
return result, nil
}
func sameOptionalID(left, right *uint64) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return *left == *right
}
// findOrCreateShopeeProduct implements #40's revival rule: a live match wins,
// a soft-deleted match is revived (keeping its prior mapping), and only when
// neither exists does the import create a minimal archive. On an existing
+12 -4
View File
@@ -3,6 +3,8 @@ package sybimport
import (
"context"
"errors"
"fmt"
"go-admin/common/clientprincipal"
"net/http"
"strconv"
"strings"
@@ -113,8 +115,10 @@ func isImportAlreadyRunning(err error) bool {
// `[必须]` This remains the authenticated manual entry point. Both it and the
// scheduler delegate to StartImport, and every downstream SYB call is read-only.
func (handler Handler) Import(c *gin.Context) {
if claimString(jwt.ExtractClaims(c)["rolekey"]) != "admin" {
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员可以开始导入"})
role := claimString(jwt.ExtractClaims(c)["rolekey"])
client, clientOK := clientprincipal.Get(c)
if role != "admin" && role != "purchaser" && !clientOK {
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以开始同步"})
return
}
var request ImportRequest
@@ -127,9 +131,13 @@ func (handler Handler) Import(c *gin.Context) {
return
}
claims := jwt.ExtractClaims(c)
result, err := StartImport(c.Request.Context(), service.DB, request, ImportActor{
actor := ImportActor{
ID: claimUint64(claims["identity"]), Name: claimString(claims["nice"]),
}, false)
}
if clientOK {
actor = ImportActor{ID: client.AuthorizedBy, Name: fmt.Sprintf("client-key:%d", client.KeyID)}
}
result, err := StartImport(c.Request.Context(), service.DB, request, actor, false)
if err != nil {
var serviceErr *ServiceError
if errors.As(err, &serviceErr) {
@@ -53,17 +53,26 @@ func TestImportRejectsNoEnabledShopBeforeConnect(t *testing.T) {
}
}
func TestImportRequiresAdminRole(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(func(c *gin.Context) { c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": "purchaser"}); c.Next() })
engine.POST("/api/admin/v1/syb-products/import", Handler{}.Import)
request := httptest.NewRequest(http.MethodPost, "/api/admin/v1/syb-products/import", bytes.NewBufferString(`{"dateFrom":"2026-08-19","dateTo":"2026-08-19"}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "只有管理员") {
t.Fatalf("采购员应被拒绝: status=%d body=%s", recorder.Code, recorder.Body.String())
func TestImportRoleBoundary(t *testing.T) {
for _, role := range []string{"admin", "purchaser", "viewer", "", "custom-role"} {
t.Run(role, func(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(func(c *gin.Context) { c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role}); c.Next() })
engine.POST("/api/admin/v1/syb-products/import", Handler{}.Import)
// Authorized roles reach JSON validation; no DB or external SYB call is made.
request := httptest.NewRequest(http.MethodPost, "/api/admin/v1/syb-products/import", bytes.NewBufferString(`{`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
want := http.StatusForbidden
if role == "admin" || role == "purchaser" {
want = http.StatusUnprocessableEntity
}
if recorder.Code != want {
t.Fatalf("role=%q status=%d want=%d body=%s", role, recorder.Code, want, recorder.Body.String())
}
})
}
}
@@ -0,0 +1,101 @@
package sybimport
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)
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 {
return []int64{1001, 1002}
}
return nil
}
db := newSyncTestDB(t)
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-28", "2026-08-29")
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"} {
if !strings.Contains(err.Error(), token) {
t.Fatalf("missing %s in %v", token, err)
}
}
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 {
t.Fatalf("unexpected retry/import boundary: pages=%d details=%d orders=%d", len(f.listCalls), f.detailCalls, report.OrderCount)
}
var count int64
if e := db.Table("syb_product").Count(&count).Error; e != nil || count != 2 {
t.Fatalf("yesterday not preserved: count=%d err=%v", count, e)
}
}
func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
freezePaginationToday(t)
for _, tc := range []struct {
name, date, message string
page int
ids []int64
calls int
}{
{"same page", "2026-08-29", "同页重复", 0, []int64{1000, 1000}, 1},
{"mixed overlap and same page", "2026-08-29", "同页重复", 2, []int64{1001, 1001}, 2},
{"overlap and invalid ID", "2026-08-29", "非法 id", 2, []int64{1001, 0}, 2},
{"historical overlap", "2026-08-28", "跨页重复", 2, []int64{1001, 1002}, 2},
} {
t.Run(tc.name, func(t *testing.T) {
f := &fakeSYB{perDay: map[string]int{tc.date: 4}}
f.pageIDs = func(_ string, start, _ int) []int64 {
if start == tc.page {
return tc.ids
}
return nil
}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), tc.date, 2, 4, 100)
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)
}
})
}
}
+26 -7
View File
@@ -13,6 +13,13 @@ import (
var bracketPattern = regexp.MustCompile(`【[^】]*】`)
// explicitSizePattern recognizes only values whose spelling carries a strong
// size signal. SYB has now been observed returning both "color,size" and
// "size,color". A color dictionary would inevitably guess at product-specific
// labels, so role reversal is allowed only when exactly one side matches this
// deliberately narrow pattern.
var explicitSizePattern = regexp.MustCompile(`(?i)^(?:均(?:码|碼|号|號)|one\s*size|free\s*size|x{0,4}[sml]|[2-9]xl|(?:加大|大|中|小)(?:码|碼|号|號)|\d+(?:\.\d+)?(?:cm|mm|m|码|碼|号|號|公分)|\d+(?:\.\d+)?(?:[-~~至到]\d+(?:\.\d+)?)?(?:斤|公斤|千克|kg))$`)
// ambiguousPattern flags leftover separators or multi-token noise after
// bracket stripping — the signal that a "clean" split still isn't reliable.
// Observed in the real SYB sample: "黑色+白色【純棉兩件裝】 簡約親膚" strips
@@ -29,8 +36,8 @@ type ParseResult struct {
Note string
}
// Parse implements the #41 rule: split on the last comma into color/size,
// strip 【...】 annotations, and classify the result. It never guesses a
// Parse implements the #41/#216 rule: split on the last comma, strip 【...】
// annotations, and classify the result. It never guesses a
// missing value and never invents a color or size that is not literally
// present in the input.
//
@@ -41,9 +48,13 @@ type ParseResult struct {
// brackets stripped, becomes the size candidate; color stays empty. A
// single token with no separator cannot be split into two dimensions
// without guessing which one it is.
// - comma present, last segment (size) and first segment(s) (color) both
// non-empty after stripping brackets, and the color candidate has no
// leftover separator/whitespace -> success.
// - comma present and exactly one side has an explicit size signal -> that
// side is size and the other side is color. This supports both observed
// SYB orders without allowing AI or a fuzzy color dictionary to swap roles.
// - comma present and neither side has an explicit size signal -> retain the
// established SYB color,size contract for backward compatibility.
// - comma present and both sides have explicit size signals -> uncertain;
// there is no safe color decision.
// - comma present but either side is empty after stripping, or the color
// candidate still carries a leftover '+' or internal whitespace -> the
// split happened but is not trustworthy -> uncertain.
@@ -65,12 +76,20 @@ func Parse(raw string) ParseResult {
return ParseResult{Size: size, Status: models.SYBParseStatusUncertain, Note: "无分隔符,仅识别出单一规格值"}
}
colorPart := stripBrackets(trimmed[:lastComma])
sizePart := stripBrackets(trimmed[lastComma+1:])
firstPart := stripBrackets(trimmed[:lastComma])
secondPart := stripBrackets(trimmed[lastComma+1:])
colorPart, sizePart := firstPart, secondPart
if colorPart == "" || sizePart == "" {
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "按逗号拆分后有一侧为空"}
}
firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart)
if firstIsSize && secondIsSize {
return ParseResult{Color: firstPart, Size: secondPart, Status: models.SYBParseStatusUncertain, Note: "逗号两侧均具有尺码特征,无法安全识别颜色"}
}
if firstIsSize {
colorPart, sizePart = secondPart, firstPart
}
if ambiguousPattern.MatchString(colorPart) {
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "颜色部分含备注文本或多个分隔符,拆分结果可能不准确"}
}
+21
View File
@@ -19,6 +19,27 @@ func TestParseRealSample_CleanColorAndSize(t *testing.T) {
}
}
func TestParseReversedSizeAndColorWhenOnlyFirstHasExplicitSizeEvidence(t *testing.T) {
result := sybimport.Parse("均碼,黑色")
if result.Color != "黑色" || result.Size != "均碼" || result.Status != models.SYBParseStatusSuccess {
t.Fatalf("expected reversed role parse 黑色/均碼 success, got %+v", result)
}
}
func TestParseKeepsTraditionalColorAndSizeOrderWhenSecondHasSizeEvidence(t *testing.T) {
result := sybimport.Parse("黑色,均码")
if result.Color != "黑色" || result.Size != "均码" || result.Status != models.SYBParseStatusSuccess {
t.Fatalf("expected normal role parse 黑色/均码 success, got %+v", result)
}
}
func TestParseTwoExplicitSizesIsUncertainInsteadOfSwapping(t *testing.T) {
result := sybimport.Parse("M,L")
if result.Status != models.SYBParseStatusUncertain || result.Color != "M" || result.Size != "L" {
t.Fatalf("expected conflicting size evidence to stay uncertain, got %+v", result)
}
}
func TestParseRealSample_AmbiguousColorWithAnnotationAndSeparator(t *testing.T) {
result := sybimport.Parse("黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】")
if result.Status != models.SYBParseStatusUncertain {
+18 -3
View File
@@ -28,7 +28,7 @@ type rawDetailSpec struct {
// result page's per-line feedback.
type ReparseOutcome struct {
SYBProductID uint64 `json:"sybProductId"`
Outcome string `json:"outcome"` // reparsed | skipped_manual | unchanged
Outcome string `json:"outcome"` // reparsed | skipped_manual | skipped_ai | unchanged
OldStatus string `json:"oldStatus"`
NewStatus string `json:"newStatus"`
}
@@ -36,6 +36,7 @@ type ReparseOutcome struct {
const (
ReparseOutcomeReparsed = "reparsed"
ReparseOutcomeSkippedManual = "skipped_manual"
ReparseOutcomeSkippedAI = "skipped_ai"
ReparseOutcomeUnchanged = "unchanged"
)
@@ -65,6 +66,11 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
outcome.NewStatus = record.ParseStatus
return nil
}
if record.AIConfirmed && !force {
outcome.Outcome = ReparseOutcomeSkippedAI
outcome.NewStatus = record.ParseStatus
return nil
}
var raw rawDetailSpec
if err := json.Unmarshal([]byte(record.RawJSON), &raw); err != nil {
@@ -76,8 +82,11 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
outcome.Outcome = ReparseOutcomeUnchanged
if force {
record.ManuallyConfirmed = false
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Update("manually_confirmed", false).Error; err != nil {
record.ManuallyConfirmed, record.AIConfirmed = false, false
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(map[string]any{
"manually_confirmed": false, "ai_confirmed": false, "ai_confidence": nil,
"ai_reason": "", "ai_confirmed_at": nil, "ai_input_fingerprint": "",
}).Error; err != nil {
return err
}
}
@@ -87,6 +96,8 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
updates := map[string]any{
"target_color": parsed.Color, "target_size": parsed.Size,
"parse_status": parsed.Status, "parse_note": parsed.Note, "manually_confirmed": false,
"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "",
"ai_confirmed_at": nil, "ai_input_fingerprint": "",
}
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(updates).Error; err != nil {
return err
@@ -140,8 +151,12 @@ func ManualCorrect(ctx context.Context, db *gorm.DB, sybProductID uint64, color,
return err
}
record.TargetColor, record.TargetSize, record.ManuallyConfirmed = color, size, true
record.AIConfirmed, record.AIConfidence, record.AIReason = false, nil, ""
record.AIConfirmedAt, record.AIInputFingerprint = nil, ""
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", sybProductID).Updates(map[string]any{
"target_color": color, "target_size": size, "manually_confirmed": true,
"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "",
"ai_confirmed_at": nil, "ai_input_fingerprint": "",
}).Error; err != nil {
return err
}
+106
View File
@@ -60,6 +60,35 @@ func TestReparseSkipsManuallyConfirmedRowByDefault(t *testing.T) {
}
}
func TestReparseSkipsAIConfirmationUnlessForced(t *testing.T) {
db := openTestDB(t)
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB())
if err != nil {
t.Fatal(err)
}
confidence := 0.95
if err := db.Model(&models.SYBProduct{}).Where("id = ?", applied.SYBProduct.ID).Updates(map[string]any{
"target_color": "AI颜色", "target_size": "AI尺码", "ai_confirmed": true,
"ai_confidence": confidence, "ai_reason": "AI 结果", "ai_input_fingerprint": strings.Repeat("c", 64),
}).Error; err != nil {
t.Fatal(err)
}
outcome, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, false)
if err != nil || outcome.Outcome != sybimport.ReparseOutcomeSkippedAI {
t.Fatalf("unforced outcome=%+v err=%v", outcome, err)
}
if _, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, true); err != nil {
t.Fatal(err)
}
var record models.SYBProduct
if err := db.First(&record, applied.SYBProduct.ID).Error; err != nil {
t.Fatal(err)
}
if record.AIConfirmed || record.AIConfidence != nil || record.AIReason != "" || record.AIInputFingerprint != "" {
t.Fatalf("forced reparse retained AI state: %+v", record)
}
}
// 可勾选强制覆盖.
func TestReparseWithForceOverridesManualCorrection(t *testing.T) {
db := openTestDB(t)
@@ -151,6 +180,83 @@ func TestManualCorrectMergesIntoArchiveLikeASuccessfulParse(t *testing.T) {
}
}
func TestManualCorrectSupersedesAIConfirmation(t *testing.T) {
db := openTestDB(t)
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB())
if err != nil {
t.Fatal(err)
}
confidence := 0.96
if err := db.Model(&models.SYBProduct{}).Where("id = ?", applied.SYBProduct.ID).Updates(map[string]any{
"ai_confirmed": true, "ai_confidence": confidence, "ai_reason": "旧 AI 结果", "ai_input_fingerprint": strings.Repeat("a", 64),
}).Error; err != nil {
t.Fatal(err)
}
corrected, err := sybimport.ManualCorrect(context.Background(), db, applied.SYBProduct.ID, "人工颜色", "人工尺码")
if err != nil {
t.Fatal(err)
}
if !corrected.ManuallyConfirmed || corrected.AIConfirmed || corrected.AIConfidence != nil || corrected.AIReason != "" || corrected.AIInputFingerprint != "" {
t.Fatalf("human correction did not supersede AI state: %+v", corrected)
}
}
func TestReimportPreservesIdenticalAIInputAndInvalidatesChangedSource(t *testing.T) {
db := openTestDB(t)
order, detail := realOrder(), realDetailB()
first, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
confidence := 0.95
if err := db.Model(&models.SYBProduct{}).Where("id = ?", first.SYBProduct.ID).Updates(map[string]any{
"target_color": "AI颜色", "target_size": "AI尺码", "ai_confirmed": true,
"ai_confidence": confidence, "ai_reason": "已确认", "ai_input_fingerprint": strings.Repeat("b", 64),
}).Error; err != nil {
t.Fatal(err)
}
same, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if !same.SYBProduct.AIConfirmed {
t.Fatal("identical re-import must preserve AI confirmation")
}
if same.SYBProduct.TargetColor != "AI颜色" || same.SYBProduct.TargetSize != "AI尺码" {
t.Fatal("identical re-import must preserve AI-confirmed target values")
}
detail.ProductSpec += " 新备注"
detail.Raw = []byte(`{"productSpec":"changed"}`)
changed, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if changed.SYBProduct.AIConfirmed || changed.SYBProduct.AIConfidence != nil || changed.SYBProduct.AIReason != "" {
t.Fatalf("changed source retained stale AI state: %+v", changed.SYBProduct)
}
}
func TestReimportNeverOverwritesManualTargetValues(t *testing.T) {
db := openTestDB(t)
order, detail := realOrder(), realDetailB()
first, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if _, err := sybimport.ManualCorrect(context.Background(), db, first.SYBProduct.ID, "人工颜色", "人工尺码"); err != nil {
t.Fatal(err)
}
detail.ProductSpec = "来源新颜色,来源新尺码"
detail.Raw = []byte(`{"productSpec":"来源新颜色,来源新尺码"}`)
updated, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if !updated.SYBProduct.ManuallyConfirmed || updated.SYBProduct.TargetColor != "人工颜色" || updated.SYBProduct.TargetSize != "人工尺码" {
t.Fatalf("re-import overwrote human decision: %+v", updated.SYBProduct)
}
}
// Regression test: ReparseOutcome originally had no json tags at all, so Go's
// default marshaling produced PascalCase keys ("SYBProductID", "OldStatus")
// instead of the camelCase the rest of this API and the admin frontend use.

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