225 lines
7.0 KiB
Go
225 lines
7.0 KiB
Go
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
|
||
}
|