update
This commit is contained in:
224
internal/service/translate.go
Normal file
224
internal/service/translate.go
Normal file
@ -0,0 +1,224 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user