diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index 8da2982..cb29b02 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -87,6 +87,7 @@ data class VisibleSpecValue( val available: Boolean, val node: SnapshotNode, val imageBounds: NodeBounds? = null, + val rawText: String = text, ) data class VisibleDimension(val key: String, val name: String, val values: List) enum class SpecPanelType { UNKNOWN, NORMAL_SCROLLABLE, NON_SCROLLABLE_CONFIRMATION, QUICK_CONFIRMATION, ORDER_CONFIRMATION } @@ -267,16 +268,23 @@ object PddScreenParser { .filterNot { isExactHeadingLabel(it.label, config) } .filterNot { node -> isExcludedOptionLabel(node.label) } .map { + val rawText = it.label + val normalizedText = if (dimensionKey == "size") { + SpecValueNormalizer.normalizeSize(rawText) + } else { + rawText + } val stateText = (listOf(it.label) + descendants(it, visibleNodes).map(SnapshotNode::label)).joinToString(" ") VisibleSpecValue( - it.label, + normalizedText, it.enabled && !stateText.containsUnavailableWord(), it, if (dimensionKey == "color") colorImageBoundsForOption(it, visibleNodes) else null, + rawText, ) } .toList() - .groupBy(VisibleSpecValue::text) + .groupBy(VisibleSpecValue::rawText) .map { (_, sameLabel) -> sameLabel.minBy { option -> safeOptionRank(option.node, visibleNodes) } } @@ -898,7 +906,13 @@ class PddProductDetailCollector( collectColors(goodsId, config, evidence, deadline, specPanelContainer, colors, prices, missing, unsupported, colorImages)?.let { return it } moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it } val sizes = linkedMapOf() - collectSizes(goodsId, config, evidence, deadline, specPanelContainer, sizes, unsupported)?.let { return it } + val sizeNormalization = SizeNormalizationTracker() + collectSizes(goodsId, config, evidence, deadline, specPanelContainer, sizes, unsupported, sizeNormalization)?.let { return it } + + if (sizeNormalization.invalid) { + sizes.clear() + missing += "spec_dimension_invalid:size" + } if (colors.isEmpty()) missing += "color" if (sizes.isEmpty()) missing += "size" @@ -1290,6 +1304,7 @@ class PddProductDetailCollector( specPanelContainer: SnapshotNode?, sizes: LinkedHashMap, unsupported: MutableSet, + normalization: SizeNormalizationTracker, ): PddCollectorResult? { val startedAt = now() var previous: List? = null @@ -1305,7 +1320,12 @@ class PddProductDetailCollector( val parsedSizes = screen.dimensions.filter { it.key == "size" }.flatMap { it.values } if (parsedSizes.isNotEmpty()) continuation = buildSizeContinuation(screen, parsedSizes) val visible = if (parsedSizes.isNotEmpty()) parsedSizes else continuationValues(screen, continuation, config) - visible.forEach { sizes[it.text] = sizes[it.text] == true || it.available } + visible.forEach { value -> + normalization.observe(value) + if (SpecValueNormalizer.isSafeSize(value.text)) { + sizes[value.text] = sizes[value.text] == true || value.available + } + } val signature = if (visible.isNotEmpty()) optionSignature(visible) else viewportSignature(screen) trace("sizes pass=$pass dimensions=${screen.dimensions.joinToString { "${it.key}:${it.values.size}" }} signature=${signature.size} collected=${sizes.size}") stable = if (previous != null && signature == previous) stable + 1 else 0 @@ -1346,6 +1366,22 @@ class PddProductDetailCollector( return null } + private class SizeNormalizationTracker { + private val sources = linkedMapOf>() + var invalid: Boolean = false + private set + + fun observe(value: VisibleSpecValue) { + if (!SpecValueNormalizer.isSafeSize(value.text)) { + invalid = true + return + } + val rawValues = sources.getOrPut(value.text) { linkedSetOf() } + rawValues += value.rawText + if (rawValues.size > 1) invalid = true + } + } + private fun recordSizeDiscovery( reason: AgentDiagnosticReason, swipes: Int, diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index ecebb41..cb074f1 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -333,10 +333,25 @@ class PurchaseRehearsalExecutor( return failure("RULE_ACTION_FAILED", message) } - private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean = - screen.selectedSummary?.contains(target) == true || - screen.dimensions.filter { it.key == dimension }.flatMap { it.values } - .any { it.text == target && (it.node.selected || it.node.checked) } + private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean { + val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values } + if (candidates.any { it.text == target && (it.node.selected || it.node.checked) }) return true + return summarySelectionMatches(screen.selectedSummary, dimension, target, candidates) + } + + private fun summarySelectionMatches( + summary: String?, + dimension: String, + target: String, + candidates: List, + ): Boolean { + val token = if (dimension == "size") SpecValueNormalizer.primarySizeToken(target) else target.trim() + if (token.isNullOrBlank() || !SpecValueNormalizer.summaryHasExactToken(summary, token)) return false + val matchingCandidates = candidates.map(VisibleSpecValue::text).distinct().filter { candidate -> + if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate) == token else candidate == token + } + return matchingCandidates.size == 1 && matchingCandidates.single() == target + } private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null) @@ -418,8 +433,8 @@ class PurchaseRehearsalExecutor( private fun verifySummary(input: PurchaseExecutionInput, observedPrice: Long?): PurchaseExecutionOutcome? { val screen = currentScreen(input) screen.problem?.let { return failure(it.code, it.message) } - val selected = listOf(input.mappedColor, input.mappedSize).filter(String::isNotBlank) - if (selected.any { screen.selectedSummary?.contains(it) != true }) { + val selected = listOf("color" to input.mappedColor, "size" to input.mappedSize).filter { it.second.isNotBlank() } + if (selected.any { (dimension, target) -> !isExactSpecSelected(screen, dimension, target) }) { return failure("PURCHASE_SPEC_NOT_MATCHED", "最终规格复核失败") } if (readQuantity() != input.quantity) return failure("PURCHASE_QUANTITY_MISMATCH", "最终数量复核失败") diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecValueNormalizer.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecValueNormalizer.kt new file mode 100644 index 0000000..8c4c310 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecValueNormalizer.kt @@ -0,0 +1,23 @@ +package cn.ilapage.goauto.agent.automation + +object SpecValueNormalizer { + private val trailingPrice = Regex("""[\s\u00a0\u3000]*(?:¥|¥)[\s\u00a0\u3000]*\d+(?:\.\d{1,2})?[\s\u00a0\u3000]*$""") + private val currency = Regex("[¥¥]") + private val sizeToken = Regex( + """^(\d+(?:\.\d+)?(?:cm|mm|m|码|号)?|\d*[a-z]+)(?=$|[\s【\[(])""", + RegexOption.IGNORE_CASE, + ) + private val summarySeparators = Regex("""[\s,,、/|;;::]+""") + + fun normalizeSize(value: String): String = value.replace(trailingPrice, "").trim() + + fun isSafeSize(value: String): Boolean = value.isNotBlank() && !currency.containsMatchIn(value) + + fun primarySizeToken(value: String): String? = + sizeToken.find(value.trim())?.groupValues?.get(1)?.takeIf(String::isNotBlank) + + fun summaryHasExactToken(summary: String?, token: String): Boolean { + if (summary.isNullOrBlank() || token.isBlank()) return false + return summary.trim().split(summarySeparators).any { it == token } + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt index e985bf8..ba55acd 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -26,6 +26,31 @@ import org.junit.Assert.assertTrue import org.junit.Test class PddProductDetailCollectorTest { + @Test + fun `parser removes only trailing size price and keeps raw evidence`() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("summary", "已选:", 20, 300, 700, 360), + node("title", "确认款式", 20, 370, 300, 410), + node("scroll", "", 0, 400, 1080, 1800, scrollable = true), + node("color-heading", "颜色分类", 20, 420, 300, 470, parentPath = "scroll"), + node("color", "黑色", 30, 500, 260, 580, clickable = true, parentPath = "scroll"), + node("size-heading", "尺码", 20, 620, 300, 670, parentPath = "scroll"), + node("size", "XL【建议121-140斤】 ¥15.78", 30, 700, 500, 780, clickable = true, parentPath = "scroll"), + node("confirm", "确定", 0, 2000, 1080, 2150, clickable = true), + ), + ) + + val size = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + .dimensions.first { it.key == "size" }.values.single() + + assertEquals("XL【建议121-140斤】", size.text) + assertEquals("XL【建议121-140斤】 ¥15.78", size.rawText) + } + @Test fun `color image crop policy accepts exact node bounds and rejects overflow`() { assertTrue(ColorImageCropPolicy.isInside(1080, 2200, NodeBounds(36, 1188, 352, 1504))) @@ -637,6 +662,25 @@ class PddProductDetailCollectorTest { assertTrue(driver.captureCount > driver.clickCount) } + @Test + fun `duplicate sizes after price cleanup reject the whole size dimension`() { + val driver = FakeCollectorDriver( + colors = listOf("黑色"), + sizePages = listOf(listOf("XL ¥10", "XL ¥12")), + prices = mapOf("黑色" to 1000L), + ) + var clock = 0L + + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + val payload = requireNotNull(result.payload) + assertEquals("completed_partial", payload.status) + assertTrue(payload.dimensions.none { it.key == "size" }) + assertTrue(payload.missing.contains("spec_dimension_invalid:size")) + assertTrue(payload.skus.all { "size" !in it.specs }) + } + @Test fun colorsAreClickedByVisualRowsInSnakeOrder() { val colors = listOf("A色", "B色", "C色", "D色", "E色", "F色") diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index 12f50ce..196a7fc 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -105,6 +105,48 @@ class PurchaseRehearsalExecutorTest { assertEquals("XL", driver.size) } + @Test + fun `long exact size can be verified by one unambiguous short summary token`() { + val target = "XL【建议121-140斤】" + val driver = FakePurchaseDriver( + sizes = listOf(target, "2XL【建议141-160斤】"), + selectedSizeSummaryOverride = "XL", + hideSizeSelectedState = true, + ) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("rehearsal_completed", outcome.resultType) + assertTrue(driver.clicked.contains(target)) + } + + @Test + fun `short summary XL never verifies a selected 2XL`() { + val driver = FakePurchaseDriver( + sizes = listOf("XL", "2XL"), + selectedSizeSummaryOverride = "2XL", + hideSizeSelectedState = true, + ) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("spec_probe_completed", outcome.resultType) + } + + @Test + fun `duplicate primary size token makes short summary unsafe`() { + val target = "XL【建议A】" + val driver = FakePurchaseDriver( + sizes = listOf(target, "XL【建议B】"), + selectedSizeSummaryOverride = "XL", + hideSizeSelectedState = true, + ) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {}) + .execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("spec_probe_completed", outcome.resultType) + } + @Test fun `persistent exact size click failure stops after three attempts`() { val driver = FakePurchaseDriver( @@ -353,6 +395,7 @@ class PurchaseRehearsalExecutorTest { private class FakePurchaseDriver( private val colors: List = listOf("黑色"), + private val sizes: List = listOf("XL"), private val priceCent: Long = 2_000, private val duplicateOpen: Boolean = false, private val bottomPurchaseEntry: Boolean = false, @@ -362,6 +405,8 @@ class PurchaseRehearsalExecutorTest { private val openPddOnFailedClick: Boolean = false, private val sizeClickResults: MutableList = mutableListOf(), private val sizeSelectsOnFailedClick: Boolean = false, + private val selectedSizeSummaryOverride: String? = null, + private val hideSizeSelectedState: Boolean = false, soldOut: Boolean = false, private val recoverSoldOutAfterPull: Boolean = false, private val pullDownSucceeds: Boolean = true, @@ -417,7 +462,7 @@ class PurchaseRehearsalExecutorTest { val nodes = mutableListOf( node("content", "", 0, 0, 1080, 2200), node("price", "¥${priceCent / 100}.${(priceCent % 100).toString().padStart(2, '0')}", 20, 300, 300, 360), - node("selected", "已选 ${color.orEmpty()} ${size.orEmpty()}", 20, 365, 700, 395), + node("selected", "已选 ${color.orEmpty()} ${if (size == null) "" else selectedSizeSummaryOverride ?: size.orEmpty()}", 20, 365, 700, 395), node("title", "确认款式", 20, 396, 300, 430), node("scroll", "", 0, 400, 1080, 950, scrollable = true), node("scroll/color-heading", "颜色分类", 20, 410, 300, 450, parentPath = "scroll"), @@ -426,8 +471,21 @@ class PurchaseRehearsalExecutorTest { nodes += node("scroll/color-$index", value, 20 + index * 220, 470, 200 + index * 220, 540, clickable = true, selected = color == value, enabled = !allSpecsUnavailable, parentPath = "scroll") } nodes += node("scroll/size-heading", "尺码", 20, 650, 300, 690, parentPath = "scroll") - val visibleSize = if (upSwipeCount >= hiddenSizeUntilUpSwipes) "XL" else "S" - nodes += node("scroll/size", visibleSize, 20, 710, 200, 780, clickable = true, selected = size == visibleSize, enabled = !allSpecsUnavailable, parentPath = "scroll") + val visibleSizes = if (upSwipeCount >= hiddenSizeUntilUpSwipes) sizes else listOf("S") + visibleSizes.forEachIndexed { index, visibleSize -> + nodes += node( + "scroll/size-$index", + visibleSize, + 20 + index * 250, + 710, + 220 + index * 250, + 780, + clickable = true, + selected = !hideSizeSelectedState && size == visibleSize, + enabled = !allSpecsUnavailable, + parentPath = "scroll", + ) + } nodes += node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText") nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true) nodes += node("order", "提交订单", 20, 1100, 500, 1180, clickable = true) @@ -445,10 +503,10 @@ class PurchaseRehearsalExecutorTest { return result } "选择规格", "免拼购买" -> panel = true - "XL" -> { + in sizes -> { sizeClickCount++ val result = sizeClickResults.removeFirstOrNull() ?: FreshActionResult.SUCCESS - if (result == FreshActionResult.SUCCESS || sizeSelectsOnFailedClick) size = "XL" + if (result == FreshActionResult.SUCCESS || sizeSelectsOnFailedClick) size = target.label return result } in colors -> color = target.label diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/SpecValueNormalizerTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/SpecValueNormalizerTest.kt new file mode 100644 index 0000000..d44606e --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/SpecValueNormalizerTest.kt @@ -0,0 +1,29 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.automation.SpecValueNormalizer +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 SpecValueNormalizerTest { + @Test + fun `only trailing price is removed`() { + assertEquals("XL【建议121-140斤】", SpecValueNormalizer.normalizeSize("XL【建议121-140斤】 ¥15.78")) + assertEquals("100cm", SpecValueNormalizer.normalizeSize("100cm\u3000¥3.69")) + assertEquals("4XL 160-170斤", SpecValueNormalizer.normalizeSize("4XL 160-170斤")) + assertEquals("XL ¥15.78起", SpecValueNormalizer.normalizeSize("XL ¥15.78起")) + assertFalse(SpecValueNormalizer.isSafeSize("XL ¥15.78起")) + } + + @Test + fun `size tokens are boundary aware`() { + assertEquals("XL", SpecValueNormalizer.primarySizeToken("XL【建议121-140斤】")) + assertEquals("2XL", SpecValueNormalizer.primarySizeToken("2XL【建议141-160斤】")) + assertEquals("100cm", SpecValueNormalizer.primarySizeToken("100cm")) + assertNull(SpecValueNormalizer.primarySizeToken("【建议121-140斤】")) + assertTrue(SpecValueNormalizer.summaryHasExactToken("已选 黑色 XL", "XL")) + assertFalse(SpecValueNormalizer.summaryHasExactToken("已选 黑色 2XL", "XL")) + } +} diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index cb3032e..79cc3af 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -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: 811176b77b923111b5ee972b421716ea224a3a96 -synchronized_at: 2026-08-29T13:43:00Z +wiki_revision: 5caf37fa5589e63e128e2beb9bc6acc24edf1cb1 +synchronized_at: 2026-08-31T01:35:05Z @@ -373,6 +373,12 @@ POST /api/agent/v1/tasks/{taskId}/result ``` 服务端验证任务当前设备和状态后,在一个事务中更新 `collection_task` 结果字段并重建结果子表。终态任务拒绝不同内容的再次提交;相同 `requestId` 幂等返回原响应。 +规格值规范化属于服务端最终契约防线: + +- size 规格值只允许剥离位于文本尾部的 ¥ / ¥ 数字价格片段,括号说明、体重区间和其他规格身份文本原样保留;Android 与服务端使用相同语义。 +- 服务端在结果完整性校验前执行规范化。规范化后为空、同维度重复、仍残留货币符号或无法由最小安全规则确定时,整维不接受,并原子丢弃引用该维度的 SKU;color 被拒时同时丢弃 colorPrices。 +- 服务端强制将任务收敛为 completed_partial,并在 missing 增加稳定值 spec_dimension_invalid:,例如 spec_dimension_invalid:size。客户端应把它展示为对应规格维度无法安全采集,不得视为完整档案,也不得自行猜测或补齐候选。 +- 被拒维度不得以污染值覆盖商品已有档案;历史任务和既有商品数据不自动清洗,须在修复后的 Agent 上重新采集。 ## Android:提交失败 diff --git a/server/app/goauto/task/admin_result_test.go b/server/app/goauto/task/admin_result_test.go index ac6094c..2188c56 100644 --- a/server/app/goauto/task/admin_result_test.go +++ b/server/app/goauto/task/admin_result_test.go @@ -3,6 +3,7 @@ package task import ( "context" "encoding/json" + "strings" "testing" "go-admin/app/goauto/models" @@ -157,6 +158,110 @@ func TestSubmitResultPersistsChildrenAndIsIdempotent(t *testing.T) { } } +func TestSubmitResultNormalizesTrailingSizePriceBeforeValidation(t *testing.T) { + db := openTaskDatabase(t) + deviceRecord, token := registerTaskDevice(t, db, "normalizer-device") + record := createTask(t, db, &deviceRecord.ID) + service := newTaskService(db) + if _, err := service.Claim(context.Background(), record.ID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil { + t.Fatal(err) + } + if _, err := service.Start(context.Background(), record.ID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil { + t.Fatal(err) + } + request := ResultRequest{ + RequestID: uuid.NewString(), Status: models.TaskStatusCompleted, + Product: ResultProduct{PDDGoodsID: record.GoodsIDSnapshot}, + Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"黑色"}}, {Key: "size", Name: "尺码", Values: []string{"XL【建议121-140斤】 ¥15.78", "2XL【建议141-160斤】 ¥15"}}}, + ColorPrices: []ResultColorPrice{{Color: "黑色", PriceCent: 1578}}, + SKUs: []ResultSKU{ + {Specs: map[string]string{"color": "黑色", "size": "XL【建议121-140斤】 ¥15.78"}, PriceCent: 1578, Available: true}, + {Specs: map[string]string{"color": "黑色", "size": "2XL【建议141-160斤】 ¥15"}, PriceCent: 1578, Available: true}, + }, + } + detail, err := service.SubmitResult(context.Background(), record.ID, request, token) + if err != nil { + t.Fatalf("submit normalized result: %v", err) + } + if detail.Task.Status != models.TaskStatusCompleted || len(detail.Dimensions) != 2 || len(detail.SKUs) != 2 { + t.Fatalf("unexpected normalized detail: %+v", detail) + } + if got := detail.Dimensions[1].Values; len(got) != 2 || got[0] != "XL【建议121-140斤】" || got[1] != "2XL【建议141-160斤】" { + t.Fatalf("size prices were not removed: %#v", got) + } + for _, sku := range detail.SKUs { + if !sku.Complete || strings.ContainsAny(sku.Specs["size"], "¥¥") { + t.Fatalf("normalized SKU is invalid: %+v", sku) + } + } +} + +func TestSubmitResultRejectsUnsafeSizeDimensionAtomically(t *testing.T) { + db := openTaskDatabase(t) + deviceRecord, token := registerTaskDevice(t, db, "invalid-size-device") + record := createTask(t, db, &deviceRecord.ID) + service := newTaskService(db) + if _, err := service.Claim(context.Background(), record.ID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil { + t.Fatal(err) + } + if _, err := service.Start(context.Background(), record.ID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil { + t.Fatal(err) + } + request := ResultRequest{ + RequestID: uuid.NewString(), Status: models.TaskStatusCompleted, + Product: ResultProduct{PDDGoodsID: record.GoodsIDSnapshot}, + Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"黑色"}}, {Key: "size", Name: "尺码", Values: []string{"XL ¥10", "XL ¥12"}}}, + ColorPrices: []ResultColorPrice{{Color: "黑色", PriceCent: 1000}}, + SKUs: []ResultSKU{ + {Specs: map[string]string{"color": "黑色", "size": "XL ¥10"}, PriceCent: 1000, Available: true}, + {Specs: map[string]string{"color": "黑色"}, PriceCent: 1000, Available: true}, + }, + } + detail, err := service.SubmitResult(context.Background(), record.ID, request, token) + if err != nil { + t.Fatalf("submit partial result: %v", err) + } + if detail.Task.Status != models.TaskStatusCompletedPartial || len(detail.Dimensions) != 1 || detail.Dimensions[0].Key != "color" { + t.Fatalf("unsafe size dimension was retained: %+v", detail) + } + if len(detail.Missing) != 1 || detail.Missing[0] != "spec_dimension_invalid:size" { + t.Fatalf("stable missing value absent: %#v", detail.Missing) + } + if len(detail.SKUs) != 1 || detail.SKUs[0].Complete { + t.Fatalf("dependent SKU was retained or surviving SKU marked complete: %+v", detail.SKUs) + } + var zeroLinks int64 + if err := db.Model(&models.CollectionSKUValue{}).Where("dimension_value_id = 0").Count(&zeroLinks).Error; err != nil { + t.Fatal(err) + } + if zeroLinks != 0 { + t.Fatalf("zero-value SKU links persisted: %d", zeroLinks) + } +} + +func TestNormalizeResultRejectsCurrencyColorWithPricesAndSKUs(t *testing.T) { + request := ResultRequest{ + Status: models.TaskStatusCompleted, + Dimensions: []ResultDimension{ + {Key: "color", Name: "颜色", Values: []string{"黑色 ¥19.9"}}, + {Key: "size", Name: "尺码", Values: []string{"XL"}}, + }, + ColorPrices: []ResultColorPrice{{Color: "黑色 ¥19.9", PriceCent: 1990}}, + SKUs: []ResultSKU{{Specs: map[string]string{"color": "黑色 ¥19.9", "size": "XL"}, PriceCent: 1990, Available: true}}, + } + + normalized := normalizeResultRequest(request) + if normalized.Request.Status != models.TaskStatusCompletedPartial || len(normalized.Request.Dimensions) != 1 || normalized.Request.Dimensions[0].Key != "size" { + t.Fatalf("currency color dimension was retained: %+v", normalized.Request) + } + if len(normalized.Request.ColorPrices) != 0 || len(normalized.Request.SKUs) != 0 { + t.Fatalf("dependent color results were retained: prices=%+v skus=%+v", normalized.Request.ColorPrices, normalized.Request.SKUs) + } + if len(normalized.Request.Missing) != 1 || normalized.Request.Missing[0] != "spec_dimension_invalid:color" { + t.Fatalf("unexpected missing values: %#v", normalized.Request.Missing) + } +} + func TestResetRejectsDeletedRuleWithoutClearingResult(t *testing.T) { db := openTaskDatabase(t) deviceRecord, _ := registerTaskDevice(t, db, "device-one") diff --git a/server/app/goauto/task/result_service.go b/server/app/goauto/task/result_service.go index 9294340..8371c36 100644 --- a/server/app/goauto/task/result_service.go +++ b/server/app/goauto/task/result_service.go @@ -81,6 +81,8 @@ type DetailResponse struct { } func (service *Service) SubmitResult(ctx context.Context, taskID uint64, request ResultRequest, token string) (DetailResponse, error) { + normalized := normalizeResultRequest(request) + request = normalized.Request if err := validateResultRequest(taskID, request); err != nil { return DetailResponse{}, err } @@ -110,7 +112,7 @@ func (service *Service) SubmitResult(ctx context.Context, taskID uint64, request if request.Product.PDDGoodsID != record.GoodsIDSnapshot { return serviceError("RESULT_GOODS_ID_MISMATCH", "结果 goods_id 与任务快照不一致") } - if err := persistResult(tx, record, request, service.Now()); err != nil { + if err := persistResult(tx, record, request, normalized.OriginalDimensionCount, service.Now()); err != nil { return err } return nil @@ -126,7 +128,7 @@ func (service *Service) SubmitResult(ctx context.Context, taskID uint64, request return detail, err } -func persistResult(tx *gorm.DB, record models.CollectionTask, request ResultRequest, now time.Time) error { +func persistResult(tx *gorm.DB, record models.CollectionTask, request ResultRequest, originalDimensionCount int, now time.Time) error { if err := deleteResultChildren(tx, record.ID); err != nil { return err } @@ -152,12 +154,19 @@ func persistResult(tx *gorm.DB, record models.CollectionTask, request ResultRequ } for _, input := range request.SKUs { specJSON, _ := json.Marshal(input.Specs) - sku := models.CollectionSKU{TaskID: record.ID, SpecKey: string(specJSON), PriceCent: input.PriceCent, Available: input.Available, Complete: len(input.Specs) == len(valueIDs)} + sku := models.CollectionSKU{TaskID: record.ID, SpecKey: string(specJSON), PriceCent: input.PriceCent, Available: input.Available, Complete: len(input.Specs) == originalDimensionCount} if err := tx.Create(&sku).Error; err != nil { return internalError(err) } for key, value := range input.Specs { - id := valueIDs[key][value] + values, exists := valueIDs[key] + if !exists { + return serviceError("RESULT_SPEC_INVALID", "SKU 引用了不存在的规格维度") + } + id, exists := values[value] + if !exists || id == 0 { + return serviceError("RESULT_SPEC_INVALID", "SKU 引用了不存在的规格值") + } if err := tx.Create(&models.CollectionSKUValue{SKUID: sku.ID, DimensionValueID: id}).Error; err != nil { return internalError(err) } @@ -187,6 +196,105 @@ func persistResult(tx *gorm.DB, record models.CollectionTask, request ResultRequ return nil } +type normalizedCollectionResult struct { + Request ResultRequest + OriginalDimensionCount int +} + +var trailingSpecPricePattern = regexp.MustCompile(`(¥|¥)[\p{Z}\s]*[0-9]+(\.[0-9]{1,2})?$`) + +func normalizeResultRequest(request ResultRequest) normalizedCollectionResult { + result := request + result.Dimensions = make([]ResultDimension, 0, len(request.Dimensions)) + result.ColorPrices = append([]ResultColorPrice(nil), request.ColorPrices...) + result.SKUs = make([]ResultSKU, 0, len(request.SKUs)) + result.Missing = append([]string(nil), request.Missing...) + + keyCounts := map[string]int{} + for _, dimension := range request.Dimensions { + keyCounts[dimension.Key]++ + } + rejected := map[string]bool{} + valueMappings := map[string]map[string]string{} + for _, dimension := range request.Dimensions { + if dimension.Key == "" || keyCounts[dimension.Key] != 1 { + result.Dimensions = append(result.Dimensions, dimension) + continue + } + normalizedValues := make([]string, 0, len(dimension.Values)) + mapping := map[string]string{} + seen := map[string]bool{} + valid := true + for _, raw := range dimension.Values { + normalized := raw + if dimension.Key == "size" { + normalized = normalizeSizeSpecValue(normalized) + } + if strings.TrimSpace(normalized) == "" || strings.ContainsAny(normalized, "¥¥") || seen[normalized] { + valid = false + break + } + seen[normalized] = true + mapping[raw] = normalized + normalizedValues = append(normalizedValues, normalized) + } + if !valid { + rejected[dimension.Key] = true + result.Missing = appendUniqueString(result.Missing, "spec_dimension_invalid:"+dimension.Key) + continue + } + dimension.Values = normalizedValues + result.Dimensions = append(result.Dimensions, dimension) + valueMappings[dimension.Key] = mapping + } + + if rejected["color"] { + result.ColorPrices = nil + } + for _, sku := range request.SKUs { + drop := false + for key := range sku.Specs { + if rejected[key] { + drop = true + break + } + } + if drop { + continue + } + copied := ResultSKU{PriceCent: sku.PriceCent, Available: sku.Available, Specs: make(map[string]string, len(sku.Specs))} + for key, value := range sku.Specs { + if mapped, ok := valueMappings[key][value]; ok { + copied.Specs[key] = mapped + } else { + copied.Specs[key] = value + } + } + result.SKUs = append(result.SKUs, copied) + } + if len(rejected) > 0 { + result.Status = models.TaskStatusCompletedPartial + } + return normalizedCollectionResult{Request: result, OriginalDimensionCount: len(request.Dimensions)} +} + +func normalizeSizeSpecValue(value string) string { + value = strings.TrimSpace(value) + if match := trailingSpecPricePattern.FindStringIndex(value); match != nil && match[1] == len(value) { + value = value[:match[0]] + } + return strings.TrimSpace(value) +} + +func appendUniqueString(values []string, value string) []string { + for _, current := range values { + if current == value { + return values + } + } + return append(values, value) +} + type productSpecValue struct { Name string `json:"name"` Selectable bool `json:"selectable"`