Files
backend_v2/internal/service/translate.go
toom1996 1b06bd5bda update
2026-09-30 11:58:32 +08:00

225 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}