fix(goauto): safely normalize specification values (#160)

This commit is contained in:
QiuSW
2026-08-31 09:40:17 +08:00
parent 5d85625905
commit 67f3a74956
9 changed files with 445 additions and 21 deletions
@@ -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,
@@ -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"))
}
}
+8 -2
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: 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:提交失败
+105
View File
@@ -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")
+112 -4
View File
@@ -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"`