fix(server): 规格键消歧,剥离 【...】 不再让不同商品塌缩成同一键 (#301)

`黑色【短袖】` 与 `黑色【長袖】` 剥离后都成了 `黑色`,档案里只有一个条目,两个
不同商品共用一份映射,必然有一半买错。#289 因此拦截,但人工匹配救不了——坏的是
键本身。线上 59 个塌缩键、45 个商品、344 条明细被卡住。

新增 sybspec.ResolveKeys:只在会产生歧义时保留括号内容。不塌缩的键与今天逐字
一致,线上一万四千多条明细中的绝大多数不受影响。

- 消歧需要同组全部原始规格,单条明细判断不了自己是否安全,因此在拿到
  shopeeProduct 之后、写档案之前做,并回写键发生变化的兄弟明细:新明细的到来
  可能让原本安全的键变成歧义,不同步会造成同组一半旧键一半新键。
- 不碰人工或 AI 已确认的明细,与 Reparse 不带 force 时的规则一致。
- 重解析同样走消歧,否则它会把键写回塌缩形式、悄悄撤销导入时的拆分。
- 键比较与产出统一去空白:SYB 对括号前的空格写法不一致,否则同一规格的两种写法
  会被误判为歧义。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-09-17 09:19:35 +08:00
co-authored by Claude Opus 5
parent 87a532f3bb
commit c1bd39496d
5 changed files with 246 additions and 0 deletions
+11
View File
@@ -100,6 +100,17 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
return err
}
if parsed.Status == models.SYBParseStatusSuccess && shopeeProduct != nil {
// `[必须]` 先消歧再入档。同一虾皮商品下 `黑色【短袖】` 与 `黑色【長袖】`
// 剥离后会塌缩成同一个 `黑色`,两个不同商品共用一份映射必然买错一半
// (#301)。消歧要看到同组全部原始规格,所以放在拿到 shopeeProduct
// 之后、写档案之前。
resolvedKeys, err := resyncShopeeProductKeys(tx, shopeeProduct.ID, rawProductSpecOf(rawJSON))
if err != nil {
return err
}
if resolvedKeys.Color != "" || resolvedKeys.Size != "" {
parsed.Color, parsed.Size = resolvedKeys.Color, resolvedKeys.Size
}
if err := mergeParsedSpec(tx, shopeeProduct.ID, parsed); err != nil {
return err
}
+11
View File
@@ -80,6 +80,17 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
if parsed.Status == models.SYBParseStatusUncertain {
parsed.Status = models.SYBParseStatusFailed
}
// `[必须]` 重解析也要走消歧,否则它会把键写回剥离后的塌缩形式,悄悄
// 撤销导入时做的拆分(#301)。两条路径必须产出同一个键。
if parsed.Status == models.SYBParseStatusSuccess && record.ShopeeProductID != nil {
resolved, err := resyncShopeeProductKeys(tx, *record.ShopeeProductID, raw.ProductSpec)
if err != nil {
return err
}
if resolved.Color != "" || resolved.Size != "" {
parsed.Color, parsed.Size = resolved.Color, resolved.Size
}
}
outcome.NewStatus = parsed.Status
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
@@ -0,0 +1,78 @@
package sybimport
import (
"encoding/json"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybspec"
"gorm.io/gorm"
)
// resyncShopeeProductKeys recomputes the purchase keys for every detail of one
// Shopee product, including the one being imported, and writes back the
// siblings whose keys changed (#301).
//
// `[必须]` 键的消歧需要同组全部原始规格,单条明细判断不了自己是否安全。新明细
// 的到来可能让一个原本安全的键变成歧义——`黑色【短袖】` 独自存在时键就是 `黑色`,
// 等 `黑色【長袖】` 进来才需要拆开。因此这里必须把兄弟明细一起重算并回写,否则
// 同组内会出现一半旧键一半新键,比塌缩本身更难排查。
func resyncShopeeProductKeys(tx *gorm.DB, shopeeProductID uint64, incomingRaw string) (sybspec.ParseResult, error) {
var siblings []models.SYBProduct
if err := tx.Where("shopee_product_id = ?", shopeeProductID).Find(&siblings).Error; err != nil {
return sybspec.ParseResult{}, err
}
raws := []string{incomingRaw}
rawByID := make(map[uint64]string, len(siblings))
for _, sibling := range siblings {
raw, ok := productSpecOf(sibling.RawJSON)
if !ok {
continue
}
rawByID[sibling.ID] = raw
raws = append(raws, raw)
}
resolved := sybspec.ResolveKeys(raws)
for _, sibling := range siblings {
raw, ok := rawByID[sibling.ID]
if !ok {
continue
}
keys := resolved[raw]
// `[必须]` 不碰人工或 AI 已确认的明细。它们的键是人(或经人确认的 AI)
// 定下的,消歧无权推翻——与 Reparse 不带 force 时跳过它们是同一条规则。
// 解析未成功的行同理,它们的键不由本函数决定。
if sibling.ParseStatus != models.SYBParseStatusSuccess ||
sibling.ManuallyConfirmed || sibling.AIConfirmed {
continue
}
if sibling.TargetColor == keys.Color && sibling.TargetSize == keys.Size {
continue
}
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", sibling.ID).
Updates(map[string]any{"target_color": keys.Color, "target_size": keys.Size}).Error; err != nil {
return sybspec.ParseResult{}, err
}
}
return resolved[incomingRaw], nil
}
func productSpecOf(rawJSON string) (string, bool) {
var payload struct {
ProductSpec string `json:"productSpec"`
}
if json.Unmarshal([]byte(rawJSON), &payload) != nil {
return "", false
}
return payload.ProductSpec, payload.ProductSpec != ""
}
// rawProductSpecOf extracts the productSpec from an already-normalised raw JSON
// payload, returning an empty string when it is absent.
func rawProductSpecOf(rawJSON string) string {
raw, _ := productSpecOf(rawJSON)
return raw
}
+77
View File
@@ -0,0 +1,77 @@
package sybspec
import (
"strings"
"unicode"
)
// ResolveKeys turns every raw productSpec of one Shopee product into its
// purchase keys, keeping a 【...】 annotation only where dropping it would make
// two different specs collide (#301).
//
// 存在的理由:stripBrackets 无差别剥离 【...】,而括号里经常是商品本身而不是备注
// ——`黑色【短袖】` 与 `黑色【長袖】` 剥离后都成了 `黑色`,档案里只有一个条目,
// 两个不同商品共用同一份映射,必然有一半买错。#289 因此拦截,但人工匹配救不了:
// 坏的是键本身。
//
// `[必须]` 消歧必须看到同一虾皮商品下的全部原始规格,单条明细无法独立判断
// `黑色` 是否安全。因此这里接收整组,而不是逐条处理。
//
// `[必须]` 不塌缩的键必须与今天的行为逐字一致。线上一万四千多条明细里绝大多数
// 不涉及塌缩,本次改动不能波及它们。
func ResolveKeys(raws []string) map[string]ParseResult {
resolved := make(map[string]ParseResult, len(raws))
colorRaws := map[string]map[string]bool{}
sizeRaws := map[string]map[string]bool{}
for _, raw := range raws {
parsed := Parse(raw)
resolved[raw] = parsed
rawColor, rawSize := RawSpecHalves(raw)
collect(colorRaws, parsed.Color, rawColor)
collect(sizeRaws, parsed.Size, rawSize)
}
for raw, parsed := range resolved {
rawColor, rawSize := RawSpecHalves(raw)
if ambiguous(colorRaws, parsed.Color) {
parsed.Color = normalizeKey(rawColor)
}
if ambiguous(sizeRaws, parsed.Size) {
parsed.Size = normalizeKey(rawSize)
}
resolved[raw] = parsed
}
return resolved
}
func collect(index map[string]map[string]bool, key, rawHalf string) {
key, rawHalf = strings.TrimSpace(key), normalizeKey(rawHalf)
if key == "" || rawHalf == "" {
return
}
if index[key] == nil {
index[key] = map[string]bool{}
}
index[key][rawHalf] = true
}
// ambiguous reports whether more than one distinct raw half collapses onto key.
func ambiguous(index map[string]map[string]bool, key string) bool {
return len(index[strings.TrimSpace(key)]) > 1
}
// normalizeKey drops every space so `黑色 【短袖】` and `黑色【短袖】` become the
// same key.
//
// `[必须]` 必须整体去空白,不能只是折叠成单个空格:SYB 对括号前的空格写法并不
// 一致,同一个规格的两种写法若被当成两个不同的原始值,就会被误判为歧义,把本来
// 安全的键也拆开。归一后的形式同时用于比较和产出,两边一致。
func normalizeKey(value string) string {
return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) {
return -1
}
return r
}, value)
}
@@ -0,0 +1,69 @@
package sybspec
import "testing"
// `[必须]` 这是本单的核心用例。`【短袖】`/`【長袖】` 是商品本身而不是备注,
// 剥离后塌缩成同一个 `黑色`,两个不同商品会共用一份映射(#301)。
func TestAnnotationIsKeptWhenItDistinguishesTwoProducts(t *testing.T) {
short := "黑色【短袖】,3XL 【建議62.5-67.5公斤】"
long := "黑色【長袖】,5XL 【建議72.5-77.5公斤】"
got := ResolveKeys([]string{short, long})
if got[short].Color != "黑色【短袖】" || got[long].Color != "黑色【長袖】" {
t.Fatalf("颜色键未拆开: %q / %q", got[short].Color, got[long].Color)
}
// 尺码不塌缩(3XL 与 5XL 本就不同),必须保持剥离后的短键。
if got[short].Size != "3XL" || got[long].Size != "5XL" {
t.Fatalf("尺码键被误改: %q / %q", got[short].Size, got[long].Size)
}
}
// `[必须]` 不塌缩时必须与今天的 Parse 逐字一致,否则会波及线上绝大多数明细。
func TestNonCollidingKeysAreUnchanged(t *testing.T) {
raws := []string{
"白色【短袖】,M 【建議42.5-47.5公斤】",
"藍色【短袖】,L 【建議47.5-52.5公斤】",
"香芋紫 【雙梅花】純棉,2XL 60.0-67.5公斤",
}
got := ResolveKeys(raws)
for _, raw := range raws {
want := Parse(raw)
if got[raw].Color != want.Color || got[raw].Size != want.Size {
t.Fatalf("%q 被改动了: 得到 %q/%q,应为 %q/%q",
raw, got[raw].Color, got[raw].Size, want.Color, want.Size)
}
}
}
// 同一个原始规格出现多次不算歧义——那只是同款重复下单。
func TestRepeatedIdenticalSpecIsNotAmbiguous(t *testing.T) {
raw := "黑色【短袖】,M 【建議42.5-47.5公斤】"
got := ResolveKeys([]string{raw, raw, raw})
if got[raw].Color != "黑色" {
t.Fatalf("重复的同一规格不该触发消歧,得到 %q", got[raw].Color)
}
}
// 尺码侧同样会塌缩,规则必须对称。
func TestSizeAnnotationIsKeptWhenItDistinguishes(t *testing.T) {
a := "黑色,均碼【薄款】"
b := "黑色,均碼【加厚】"
got := ResolveKeys([]string{a, b})
if got[a].Size != "均碼【薄款】" || got[b].Size != "均碼【加厚】" {
t.Fatalf("尺码键未拆开: %q / %q", got[a].Size, got[b].Size)
}
// 颜色两边相同,不该被消歧波及。
if got[a].Color != "黑色" || got[b].Color != "黑色" {
t.Fatalf("颜色键被误改: %q / %q", got[a].Color, got[b].Color)
}
}
// 括号前有无空格是 SYB 的不一致写法,不应产生两个不同的键。
func TestWhitespaceBeforeBracketDoesNotSplitTheKey(t *testing.T) {
a := "黑色 【短袖】,M"
b := "黑色【短袖】,L"
got := ResolveKeys([]string{a, b})
if got[a].Color != "黑色" || got[b].Color != "黑色" {
t.Fatalf("同一规格的两种写法不该被当成歧义: %q / %q", got[a].Color, got[b].Color)
}
}