This commit is contained in:
toom1996
2026-09-30 11:58:32 +08:00
parent 065d57457e
commit 1b06bd5bda
24 changed files with 2588 additions and 1659 deletions

View 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
}