update
This commit is contained in:
@ -300,6 +300,11 @@ func (s *articleService) DeleteImage(ctx context.Context, imageID uint32) error
|
||||
log.Printf("[warn] 重算走秀 %d 去重失败(重复标记可能陈旧): %v", runwayID, err)
|
||||
}
|
||||
}
|
||||
// 跨文章清理:本图若是其它文章近重复的「源」,那些文章的 dup_of 需重算/清除
|
||||
// (ReresolveRunwayDedup 只算本集内,不会碰其它文章指向本图的标记)。
|
||||
if err := s.articles.ReresolveRunwayDupOfSources(ctx, []uint32{imageID}); err != nil {
|
||||
log.Printf("[warn] 跨文章重算走秀图 %d 去重失败(重复标记可能陈旧): %v", imageID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -329,6 +334,11 @@ func (s *articleService) SetDeleted(ctx context.Context, id uint32, deleted uint
|
||||
// 仍被其他图集引用的 key 不会被误删(内容寻址共享 key 安全);入队失败仅告警,
|
||||
// 图集已下架、图片已软删,S4文件留待后续手动清理或周期性兜底扫描。
|
||||
func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||
// 下架前先取存活图片 id:删整篇后,其它文章中 dup_of 指向本篇图片的标记需跨文章重算/清除。
|
||||
delIDs, err := s.articles.GetRunwayLiveImageIDs(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys, err := s.articles.SoftDeleteRunwayImages(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -341,6 +351,12 @@ func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||
log.Printf("[warn] 下架图集 %d 入队清理S4失败(文件将暂留): %v", id, err)
|
||||
}
|
||||
}
|
||||
// 跨文章去重清理:删除整篇后,其它文章中 dup_of 指向本篇图片的标记重算/清除。
|
||||
if len(delIDs) > 0 {
|
||||
if err := s.articles.ReresolveRunwayDupOfSources(ctx, delIDs); err != nil {
|
||||
log.Printf("[warn] 跨文章重算走秀 %d 去重失败(重复标记可能陈旧): %v", id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@ type IngestService struct {
|
||||
del storage.Deleter // 删除器(S4或本地,media_cleanup 真删用)
|
||||
local *storage.LocalUploader // S4失败时的兜底落地
|
||||
httpClient *http.Client
|
||||
translate *TranslateService // 标题中文翻译(可为 nil:测试/未启用时跳过自动补全)
|
||||
}
|
||||
|
||||
// NewIngestService 创建入库服务。
|
||||
@ -45,7 +46,7 @@ type IngestService struct {
|
||||
// uploader: 主上传器(S4或本地)
|
||||
// del: 删除器(S4或本地),用于 media_cleanup 真删S4/本地孤儿文件
|
||||
// local: 本地兜底上传器(S4上传失败时回退,避免图片完全丢失)
|
||||
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader, downloadProxy string) *IngestService {
|
||||
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader, downloadProxy string, translateSvc *TranslateService) *IngestService {
|
||||
// 下载代理:download_proxy(或 INGEST_DOWNLOAD_PROXY)非空时,下载客户端经该代理访问源站;
|
||||
// 仅作用于图片下载,S4 上传走独立 s3 客户端、pgx 数据库连接不走 http,均不受影响。
|
||||
// 留空回退 http.ProxyFromEnvironment(默认直连,仍可被系统 HTTP(S)_PROXY 影响)。
|
||||
@ -74,6 +75,7 @@ func NewIngestService(repo repository.IngestRepository, brandRepo repository.Bra
|
||||
uploader: uploader,
|
||||
del: del,
|
||||
local: local,
|
||||
translate: translateSvc,
|
||||
// 图片下载是并发执行的(见 imageFetchConcurrency),连接池须匹配并发度:
|
||||
// Go 默认 MaxIdleConnsPerHost=2,多余连接会在每轮下载时反复重建、白付 TLS 握手开销。
|
||||
httpClient: &http.Client{
|
||||
@ -372,6 +374,15 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
ImageCount: imageCount,
|
||||
Status: model.StatusPending,
|
||||
}
|
||||
|
||||
// 入库自动补全中文标题:spider 未给 title_cn 时,用翻译服务按「记忆优先、规则兜底」推导;
|
||||
// 已给则保留(绝不覆盖爬虫提供/已有的翻译)。翻译服务未注入(nil)时跳过。
|
||||
if rw.TitleCn == "" && s.translate != nil {
|
||||
if cn, terr := s.translate.SuggestTitleCn(ctx, rw.TitleEn); terr == nil {
|
||||
rw.TitleCn = cn
|
||||
}
|
||||
}
|
||||
|
||||
for i := range images {
|
||||
images[i].BrandID = brandID
|
||||
}
|
||||
@ -531,9 +542,19 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
|
||||
// 3) 直写正式表(status=pending)
|
||||
writeStart := time.Now()
|
||||
|
||||
// 街拍入库自动补全中文标题(同 runway:spider 未给才补,且翻译服务需注入)。
|
||||
titleCn := p.TitleCn
|
||||
if titleCn == "" && s.translate != nil {
|
||||
if cn, terr := s.translate.SuggestTitleCn(ctx, title); terr == nil {
|
||||
titleCn = cn
|
||||
}
|
||||
}
|
||||
|
||||
snap := &model.StreetSnap{
|
||||
JobID: job.ID,
|
||||
Title: title, // 街拍单标题,爬虫优先填 title_en(已归一化,与查重口径一致)
|
||||
TitleCn: titleCn,
|
||||
Year: p.Year,
|
||||
City: p.City,
|
||||
Cover: cover,
|
||||
|
||||
@ -44,7 +44,7 @@ func TestProcessMediaCleanup(t *testing.T) {
|
||||
t.Fatalf("预置文件失败: %v", err)
|
||||
}
|
||||
|
||||
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up, "")
|
||||
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up, "", nil)
|
||||
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/abc.jpg"]}`}
|
||||
svc.processMediaCleanup(context.Background(), job)
|
||||
|
||||
@ -60,7 +60,7 @@ func TestProcessMediaCleanupSkipsReferenced(t *testing.T) {
|
||||
del := &countingDeleter{LocalUploader: up}
|
||||
|
||||
// 引用计数恒为 1(仍被其他图集使用),不应真删。
|
||||
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up, "")
|
||||
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up, "", nil)
|
||||
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/shared.jpg"]}`}
|
||||
svc.processMediaCleanup(context.Background(), job)
|
||||
|
||||
|
||||
@ -401,6 +401,10 @@ func (s *streetSnapService) DeleteImage(ctx context.Context, imageID uint32) err
|
||||
log.Printf("[warn] 重算街拍 %d 去重失败(重复标记可能陈旧): %v", snapID, err)
|
||||
}
|
||||
}
|
||||
// 跨文章清理:本图若是其它街拍近重复的「源」,那些街拍的 dup_of 需重算/清除。
|
||||
if err := s.repo.ReresolveSnapDupOfSources(ctx, []uint32{imageID}); err != nil {
|
||||
log.Printf("[warn] 跨文章重算街拍图 %d 去重失败(重复标记可能陈旧): %v", imageID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -425,6 +429,11 @@ func (s *streetSnapService) SetDeleted(ctx context.Context, id uint32, deleted u
|
||||
// PurgeGallery 下架街拍并异步清理S4孤儿图:逻辑同 articleService.PurgeGallery,
|
||||
// 仅仓储调用换成街拍的 SoftDeleteSnapImages / SetSnapDeleted。
|
||||
func (s *streetSnapService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||
// 下架前先取存活图片 id:删整篇后,其它街拍中 dup_of 指向本篇图片的标记需跨文章重算/清除。
|
||||
delIDs, err := s.repo.GetSnapLiveImageIDs(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys, err := s.repo.SoftDeleteSnapImages(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -437,5 +446,11 @@ func (s *streetSnapService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||
log.Printf("[warn] 下架街拍 %d 入队清理S4失败(文件将暂留): %v", id, err)
|
||||
}
|
||||
}
|
||||
// 跨文章去重清理:删除整篇后,其它街拍中 dup_of 指向本篇图片的标记重算/清除。
|
||||
if len(delIDs) > 0 {
|
||||
if err := s.repo.ReresolveSnapDupOfSources(ctx, delIDs); err != nil {
|
||||
log.Printf("[warn] 跨文章重算街拍 %d 去重失败(重复标记可能陈旧): %v", id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
89
internal/service/translate_test.go
Normal file
89
internal/service/translate_test.go
Normal file
@ -0,0 +1,89 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
|
||||
type fakeTranslateBrandRepo struct{ repository.BrandRepository }
|
||||
|
||||
func (fakeTranslateBrandRepo) CrawlBrands(ctx context.Context, brandID uint32) ([]model.Brand, error) {
|
||||
return []model.Brand{
|
||||
{NameEn: "Chanel", NameCn: "香奈儿"},
|
||||
{NameEn: "Acne Studios", NameCn: "阿尼亚"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type fakeTranslateMem struct {
|
||||
m map[string]string
|
||||
repository.TranslateRepository
|
||||
}
|
||||
|
||||
func (f *fakeTranslateMem) GetTitleCnMemory(ctx context.Context, titleEn string) (string, bool, error) {
|
||||
v, ok := f.m[titleEn]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
func (f *fakeTranslateMem) UpsertTitleCnMemory(ctx context.Context, titleEn, titleCn string) error {
|
||||
if f.m == nil {
|
||||
f.m = map[string]string{}
|
||||
}
|
||||
f.m[titleEn] = titleCn
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTranslateServiceForTest() *TranslateService {
|
||||
return &TranslateService{brandRepo: fakeTranslateBrandRepo{}, memRepo: &fakeTranslateMem{m: map[string]string{}}}
|
||||
}
|
||||
|
||||
func TestRuleTranslate(t *testing.T) {
|
||||
brandCN := map[string]string{"chanel": "香奈儿", "acne studios": "阿尼亚"}
|
||||
cases := []struct {
|
||||
en string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"Spring 2024 Ready-to-Wear", "春夏 2024 成衣", true},
|
||||
{"London Fashion Week Street Style", "伦敦 时装周 街拍", true},
|
||||
{"Chanel Spring 2024 Ready-to-Wear", "香奈儿 春夏 2024 成衣", true},
|
||||
{"Acne Studios Fall 2023 Couture", "阿尼亚 秋冬 2023 高级定制", true},
|
||||
// 含未知品牌 → 未完整翻译 → 失败(不写脏数据)
|
||||
{"UnknownBrand Spring 2024 Ready-to-Wear", "", false},
|
||||
{"", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := RuleTranslate(c.en, brandCN)
|
||||
if ok != c.ok || got != c.want {
|
||||
t.Errorf("RuleTranslate(%q) = (%q, %v), want (%q, %v)", c.en, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestTitleCnMemory(t *testing.T) {
|
||||
svc := newTranslateServiceForTest()
|
||||
ctx := context.Background()
|
||||
if err := svc.Remember(ctx, "Foobar Fall 2023 Couture", "自定义中文"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := svc.SuggestTitleCn(ctx, "Foobar Fall 2023 Couture")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "自定义中文" {
|
||||
t.Fatalf("记忆未优先命中: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestTitleCnRule(t *testing.T) {
|
||||
svc := newTranslateServiceForTest()
|
||||
got, err := svc.SuggestTitleCn(context.Background(), "Chanel Spring 2024 Ready-to-Wear")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "香奈儿 春夏 2024 成衣" {
|
||||
t.Fatalf("规则翻译不符: got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user