fix(goauto): safely normalize specification values (#160)
This commit is contained in:
+40
-4
@@ -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<VisibleSpecValue>)
|
||||
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<String, Boolean>()
|
||||
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<String, Boolean>,
|
||||
unsupported: MutableSet<String>,
|
||||
normalization: SizeNormalizationTracker,
|
||||
): PddCollectorResult? {
|
||||
val startedAt = now()
|
||||
var previous: List<String>? = 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<String, MutableSet<String>>()
|
||||
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,
|
||||
|
||||
+21
-6
@@ -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<VisibleSpecValue>,
|
||||
): 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", "最终数量复核失败")
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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色")
|
||||
|
||||
@@ -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<String> = listOf("黑色"),
|
||||
private val sizes: List<String> = 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<FreshActionResult> = 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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -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:<dimensionKey>,例如 spec_dimension_invalid:size。客户端应把它展示为对应规格维度无法安全采集,不得视为完整档案,也不得自行猜测或补齐候选。
|
||||
- 被拒维度不得以污染值覆盖商品已有档案;历史任务和既有商品数据不自动清洗,须在修复后的 Agent 上重新采集。
|
||||
|
||||
## Android:提交失败
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user