package service import ( "context" "regexp" "sort" "strings" "sync" "fashionapi/internal/repository" ) // TranslateService 标题中英文翻译:规则兜底 + 人工记忆优先。 // // 设计目标:标题高度固定(品牌/年份/季节/系列/城市),绝大多数可规则翻译; // 人工在后台纠正过的中文标题写入记忆表,之后无论入库还是编辑都优先命中,永久精确。 // 入库只读取记忆(不写入),避免把规则猜测的半成品固化成「真理」。 type TranslateService struct { brandRepo repository.BrandRepository memRepo repository.TranslateRepository brandMu sync.Mutex brandOnce bool brandMap map[string]string // name_en(小写) -> name_cn } // NewTranslateService 创建翻译服务。brandRepo / memRepo 任一为 nil 时,对应能力自动降级 // (品牌映射为空 / 记忆不命中),仅影响翻译覆盖率,不会 panic。 func NewTranslateService(brandRepo repository.BrandRepository, memRepo repository.TranslateRepository) *TranslateService { return &TranslateService{brandRepo: brandRepo, memRepo: memRepo} } // SuggestTitleCn 给出 title_en 的中文翻译:记忆优先,规则兜底。 // 规则无法完整翻译(存在未覆盖的英文词)时返回空串,绝不写半吊子脏数据。 func (s *TranslateService) SuggestTitleCn(ctx context.Context, titleEn string) (string, error) { en := strings.TrimSpace(titleEn) if en == "" { return "", nil } if s.memRepo != nil { if cn, ok, err := s.memRepo.GetTitleCnMemory(ctx, en); err != nil { return "", err } else if ok && cn != "" { return cn, nil } } cn, _ := s.ruleTranslate(en) return cn, nil } // Remember 记录一条「英文标题 → 中文标题」的人工映射(幂等 upsert)。 func (s *TranslateService) Remember(ctx context.Context, titleEn, titleCn string) error { en := strings.TrimSpace(titleEn) cn := strings.TrimSpace(titleCn) if en == "" || cn == "" || s.memRepo == nil { return nil } return s.memRepo.UpsertTitleCnMemory(ctx, en, cn) } // ── 规则翻译 ─────────────────────────────────────────────────────────────────── // repItem 静态替换项:英文短语 → 中文。 type repItem struct { pat string cn string } // staticPatterns 固定映射。组合词(Spring Summer)须排在单词(Spring)之前, // 由 init 统一按长度降序编译,保证优先匹配。 var staticPatterns = []repItem{ // 街拍短语 {"Fashion Week", "时装周"}, {"Street Style", "街拍"}, {"Street Snap", "街拍"}, {"Street Snaps", "街拍"}, // 季节(组合在前) {"Spring Summer", "春夏"}, {"Fall Winter", "秋冬"}, {"Autumn Winter", "秋冬"}, {"Pre-Spring", "早春"}, {"Pre-Fall", "早秋"}, {"Spring", "春夏"}, {"Summer", "盛夏"}, {"Autumn", "秋冬"}, {"Winter", "秋冬"}, {"Fall", "秋冬"}, // 系列 / 品类 {"Ready-to-Wear", "成衣"}, {"Ready To Wear", "成衣"}, {"Couture", "高级定制"}, {"Resort", "度假"}, {"Menswear", "男装"}, {"Womenswear", "女装"}, {"Mens", "男装"}, {"Womens", "女装"}, {"RTW", "成衣"}, // 城市 {"New York", "纽约"}, {"Hong Kong", "香港"}, {"Los Angeles", "洛杉矶"}, {"San Francisco", "旧金山"}, {"London", "伦敦"}, {"Paris", "巴黎"}, {"Milan", "米兰"}, {"Tokyo", "东京"}, {"Seoul", "首尔"}, {"Shanghai", "上海"}, {"Beijing", "北京"}, {"Berlin", "柏林"}, {"Copenhagen", "哥本哈根"}, {"Florence", "佛罗伦萨"}, {"Madrid", "马德里"}, {"Rome", "罗马"}, {"Sydney", "悉尼"}, {"Toronto", "多伦多"}, {"Chicago", "芝加哥"}, } type compiledRep struct { re *regexp.Regexp cn string } // staticReplacements 编译后的静态替换表(按短语长度降序)。 var staticReplacements []compiledRep func init() { sorted := append([]repItem{}, staticPatterns...) sort.Slice(sorted, func(i, j int) bool { return len(sorted[i].pat) > len(sorted[j].pat) }) for _, it := range sorted { staticReplacements = append(staticReplacements, compiledRep{ re: regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(it.pat) + `\b`), cn: it.cn, }) } } // 非中/非数/非字母/非空白的标点一律清掉(避免替换后遗留孤标点符号)。 var nonWordRe = regexp.MustCompile(`[^\p{Han}\p{Hangul}\p{Hiragana}\p{Katakana}0-9A-Za-z\s]`) // 仍含 ASCII 字母 ⇒ 存在未覆盖的英文词 ⇒ 未完整翻译。 var asciiLetterRe = regexp.MustCompile(`[A-Za-z]+`) // ruleTranslate 翻译服务的规则分支:直接用共享的 RuleTranslate(注入已加载的品牌映射)。 func (s *TranslateService) ruleTranslate(en string) (string, bool) { s.ensureBrandMap() return RuleTranslate(en, s.brandMap) } // RuleTranslate 纯规则翻译(不含记忆):brandCN 为 name_en(小写)→name_cn 映射。 // 品牌名按长度降序、词边界(\b)匹配,避免子串误替(如 brand "On" 误伤 "fashion")。 // 所有英文词都被覆盖才成功,否则返回 ("", false) —— 绝不写半吊子脏数据。 // // 导出供一次性脚本(dbtool filltitlecn)复用,避免规则逻辑重复实现。 func RuleTranslate(en string, brandCN map[string]string) (string, bool) { if strings.TrimSpace(en) == "" { return "", false } lower := strings.ToLower(en) for _, r := range staticReplacements { lower = r.re.ReplaceAllString(lower, r.cn) } if len(brandCN) > 0 { keys := make([]string, 0, len(brandCN)) for k := range brandCN { keys = append(keys, k) } sort.Slice(keys, func(i, j int) bool { return len(keys[i]) > len(keys[j]) }) for _, k := range keys { cn := brandCN[k] if cn == "" { continue } if re := brandRegex(k); re != nil { lower = re.ReplaceAllString(lower, cn) } } } clean := nonWordRe.ReplaceAllString(lower, " ") if asciiLetterRe.MatchString(clean) { return "", false } return strings.Join(strings.Fields(clean), " "), true } // brandReCache 缓存品牌名编译后的正则,避免每次翻译重复编译(ingest 高频场景)。 var brandReCache sync.Map // en(lower) -> *regexp.Regexp func brandRegex(en string) *regexp.Regexp { if v, ok := brandReCache.Load(en); ok { return v.(*regexp.Regexp) } re := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(en) + `\b`) brandReCache.Store(en, re) return re } // ensureBrandMap 懒加载品牌「英文→中文」映射(按名称长度降序 + 词边界正则),并发安全。 func (s *TranslateService) ensureBrandMap() { s.brandMu.Lock() defer s.brandMu.Unlock() if s.brandOnce { return } s.brandOnce = true if s.brandRepo == nil { s.brandMap = map[string]string{} return } brands, err := s.brandRepo.CrawlBrands(context.Background(), 0) if err != nil || len(brands) == 0 { s.brandMap = map[string]string{} return } m := make(map[string]string, len(brands)) for _, b := range brands { if b.NameEn == "" { continue } m[strings.ToLower(b.NameEn)] = strings.TrimSpace(b.NameCn) } s.brandMap = m }