diff --git a/apps/services/modules/user/model_translate.go b/apps/services/modules/user/model_translate.go index 01e8e494..479c0fa9 100644 --- a/apps/services/modules/user/model_translate.go +++ b/apps/services/modules/user/model_translate.go @@ -9,6 +9,7 @@ import ( "yunyan/comm" "yunyan/lego/core" "yunyan/lego/core/cbase" + "yunyan/lego/sys/log" "yunyan/lego/sys/postgres" alitranslate "yunyan/sys/aliyun/translate" ) @@ -28,14 +29,67 @@ import ( // 但也不能永久缓存——后台改了配置得能生效,所以给个短 TTL。 const mtCacheTTL = 60 * time.Second +// mtProviderPriority 服务端**真正接得上**的机器翻译服务商,值越小越优先。 +// +// ⚠️ 后台「第三方服务配置」里能建出、也能启用的 MT 服务远不止这些 +// (mt_volcano / mt_azure 都有现成模板、凭据也填得进去),但**配得出 ≠ 接得上**: +// 不在这张表里的服务商,服务端没有任何一行代码会去调它。 +// +// 挑选时必须先按这张表过滤。不过滤的后果不是「少一家可选」,而是**整个翻译不可用**: +// 同一类别下启用了多家时,挑中哪一个纯看 postgres 的返回顺序,挑中没接的那家就直接 +// 报「未配置机器翻译服务」,哪怕旁边正躺着一个凭据齐全的 mt_alibaba,而且错误信息 +// 指向「没配置」,只会把人引去后台反复检查明明填好了的凭据。 +// 2026-09-14 线上就是这样:EAIMAR 下三家 MT 全启用,随手挑中了 mt_volcano。 +// +// 接新的一家:在 sys/ 下实现(bytedance=volcano、microsoft=azure、google 都已有 sys 包, +// 但只有阿里实现了自动检测源语种的 TranslateDetect),这里加一行, +// 并在 resolveTranslator 下半段按 provider 加一个构造分支。两处都改才算接上。 +var mtProviderPriority = map[string]int{ + "alibaba": 0, +} + +// mtNormProvider 统一 provider 的大小写与空白,库里的值是人手填的。 +func mtNormProvider(p string) string { return strings.TrimSpace(strings.ToLower(p)) } + +// pickMTSvc 从候选里挑一个服务端接得上的机器翻译服务。 +// +// 排序口径:先按作用域(本应用的行优先于全局行),再按 mtProviderPriority。 +// 第二个返回值是**被跳过的未接入服务**,只用于日志——「后台配了三家,一家都没用上」 +// 这件事必须能从日志里一眼看出来,否则排查只能靠猜。 +// +// 抽成纯函数是为了能脱离 postgres 测试,见 model_translate_test.go。 +func pickMTSvc(svcs []*comm.ThirdSvcConfig, app string) (picked *comm.ThirdSvcConfig, skipped []string) { + var bestScope, bestRank int + for _, s := range svcs { + if s == nil || !s.Enable || !comm.CategoriesHas(s.Categories, comm.SvcCatMT) { + continue + } + rank, ok := mtProviderPriority[mtNormProvider(s.Provider)] + if !ok { + skipped = append(skipped, s.Id+"/"+s.Provider) + continue + } + // 作用域:本应用的行优先于全局行(作用域归一后全局行已不再产生,留着兼容存量) + scope := 1 + if app != "" && s.AppName == app { + scope = 0 + } + if picked == nil || scope < bestScope || (scope == bestScope && rank < bestRank) { + picked, bestScope, bestRank = s, scope, rank + } + } + return picked, skipped +} + type modelTranslateComp struct { cbase.ModuleCompBase module *User - mu sync.RWMutex - cached alitranslate.ISys - cachedId string - cachedAt time.Time + mu sync.RWMutex + cached alitranslate.ISys + cachedId string + cachedProvider string + cachedAt time.Time } func (this *modelTranslateComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) (err error) { @@ -46,15 +100,16 @@ func (this *modelTranslateComp) Init(service core.IService, module core.IModule, // resolveTranslator 取本应用可用的机器翻译服务。 // -// 作用域口径与 resolveThirdSvcs 一致:应用行(app_name=)优先于全局行(app_name=''), -// 只取启用的、类别含 comm.SvcCatMT 的服务。 +// 作用域口径与 resolveThirdSvcs 一致:应用行(app_name=)优先于全局行(app_name=”), +// 候选须同时满足三条:启用、类别含 comm.SvcCatMT、**provider 在 mtProviderPriority 里** +// (即服务端真的接了这家)。挑选见 pickMTSvc。 // 返回 svcId/provider 供落日志与响应回带,便于排查"到底用的哪家"。 func (this *modelTranslateComp) resolveTranslator() (svcId, provider string, t alitranslate.ISys, err error) { this.mu.RLock() if this.cached != nil && time.Since(this.cachedAt) < mtCacheTTL { - svcId, t = this.cachedId, this.cached + svcId, provider, t = this.cachedId, this.cachedProvider, this.cached this.mu.RUnlock() - return svcId, "alibaba", t, nil + return svcId, provider, t, nil } this.mu.RUnlock() @@ -68,14 +123,14 @@ func (this *modelTranslateComp) resolveTranslator() (svcId, provider string, t a } } - var picked *comm.ThirdSvcConfig - for _, s := range svcs { - if !s.Enable || !comm.CategoriesHas(s.Categories, comm.SvcCatMT) { - continue - } - if picked == nil || (picked.AppName == "" && s.AppName == app && app != "") { - picked = s - } + picked, skipped := pickMTSvc(svcs, app) + if len(skipped) > 0 { + // 「后台启用了、服务端没接」不是错误,但一定要能看见——它是「凭据明明填了 + // 却报未配置」的唯一线索。 + this.module.Warn("resolveTranslator: 跳过服务端未接入的 MT 服务", + log.Field{Key: "app", Value: app}, + log.Field{Key: "skipped", Value: strings.Join(skipped, ",")}, + log.Field{Key: "picked", Value: pickedIdOf(picked)}) } if picked == nil { return "", "", nil, nil // 未配置:调用方回 TranslateNotConfigured @@ -86,9 +141,10 @@ func (this *modelTranslateComp) resolveTranslator() (svcId, provider string, t a return picked.Id, picked.Provider, nil, ferr } - // 目前只实现了阿里云。换别家时在这里按 provider 分支即可 - // (sys/ 下已有 bytedance/google/microsoft 的翻译实现可接)。 - if strings.TrimSpace(strings.ToLower(picked.Provider)) != "alibaba" { + // 目前只实现了阿里云。接别家时在这里加分支,并同步 mtProviderPriority—— + // 只加一处的话:只加表 → 走到这里返回 nil 仍报「未配置」;只加分支 → 上面过滤时 + // 就被当成未接入跳过了,分支根本执行不到。 + if mtNormProvider(picked.Provider) != "alibaba" { return picked.Id, picked.Provider, nil, nil } @@ -105,7 +161,15 @@ func (this *modelTranslateComp) resolveTranslator() (svcId, provider string, t a } this.mu.Lock() - this.cached, this.cachedId, this.cachedAt = sys, picked.Id, time.Now() + this.cached, this.cachedId, this.cachedProvider, this.cachedAt = sys, picked.Id, mtNormProvider(picked.Provider), time.Now() this.mu.Unlock() return picked.Id, picked.Provider, sys, nil } + +// pickedIdOf 日志用:挑不出服务时打 "-" 而不是崩在空指针上。 +func pickedIdOf(s *comm.ThirdSvcConfig) string { + if s == nil { + return "-" + } + return s.Id +} diff --git a/apps/services/modules/user/model_translate_test.go b/apps/services/modules/user/model_translate_test.go new file mode 100644 index 00000000..a5ec1eb9 --- /dev/null +++ b/apps/services/modules/user/model_translate_test.go @@ -0,0 +1,86 @@ +package user + +import ( + "strings" + "testing" + + "yunyan/comm" +) + +func mtSvc(app, id, provider string, enable bool) *comm.ThirdSvcConfig { + return &comm.ThirdSvcConfig{ + AppName: app, + Id: id, + Provider: provider, + Categories: "3", // comm.SvcCatMT + Enable: enable, + } +} + +// 后台同时启用了三家 MT,只有阿里是服务端真接了的:必须挑阿里, +// 而不是看 postgres 先返回谁。2026-09-14 线上翻译整体报「未配置机器翻译服务」 +// 就是因为没这条规则、随手挑中了 mt_volcano。 +func TestPickMTSvcSkipsUnimplementedProviders(t *testing.T) { + // 顺序刻意把没接入的排在最前,模拟库的返回顺序不利的情况 + svcs := []*comm.ThirdSvcConfig{ + mtSvc("EAIMAR", "mt_volcano", "volcano", true), + mtSvc("EAIMAR", "mt_azure", "azure", true), + mtSvc("EAIMAR", "mt_alibaba", "alibaba", true), + } + picked, skipped := pickMTSvc(svcs, "EAIMAR") + if picked == nil || picked.Id != "mt_alibaba" { + t.Fatalf("应挑中 mt_alibaba,实际 %v", pickedIdOf(picked)) + } + if len(skipped) != 2 || !strings.Contains(strings.Join(skipped, ","), "mt_volcano") { + t.Fatalf("未接入的两家应记进 skipped 供日志排查,实际 %v", skipped) + } +} + +// 全都没接入时返回 nil(调用方回 TranslateNotConfigured), +// 并且把跳过的列出来——否则「配了却报未配置」无从查起。 +func TestPickMTSvcAllUnimplemented(t *testing.T) { + svcs := []*comm.ThirdSvcConfig{ + mtSvc("EAIMAR", "mt_volcano", "volcano", true), + mtSvc("EAIMAR", "mt_azure", "azure", true), + } + picked, skipped := pickMTSvc(svcs, "EAIMAR") + if picked != nil { + t.Fatalf("没有接得上的服务时应返回 nil,实际 %v", picked.Id) + } + if len(skipped) != 2 { + t.Fatalf("skipped 应含两家,实际 %v", skipped) + } +} + +// 停用的、类别不含 MT 的,一律不参与。 +func TestPickMTSvcIgnoresDisabledAndOtherCategories(t *testing.T) { + other := mtSvc("EAIMAR", "stt_azure", "alibaba", true) + other.Categories = "1" // STT + svcs := []*comm.ThirdSvcConfig{ + mtSvc("EAIMAR", "mt_alibaba", "alibaba", false), // 停用 + other, + } + if picked, _ := pickMTSvc(svcs, "EAIMAR"); picked != nil { + t.Fatalf("停用/非 MT 类别不应被选中,实际 %v", picked.Id) + } +} + +// 本应用的行优先于全局行(作用域归一后全局行已不再产生,这条守存量数据)。 +func TestPickMTSvcPrefersAppScope(t *testing.T) { + svcs := []*comm.ThirdSvcConfig{ + mtSvc("", "mt_alibaba", "alibaba", true), + mtSvc("EAIMAR", "mt_alibaba", "alibaba", true), + } + picked, _ := pickMTSvc(svcs, "EAIMAR") + if picked == nil || picked.AppName != "EAIMAR" { + t.Fatalf("应优先取本应用的行,实际 %v", picked) + } +} + +// provider 是人手填的,大小写/空格不该影响匹配。 +func TestPickMTSvcNormalizesProvider(t *testing.T) { + svcs := []*comm.ThirdSvcConfig{mtSvc("EAIMAR", "mt_alibaba", " Alibaba ", true)} + if picked, _ := pickMTSvc(svcs, "EAIMAR"); picked == nil { + t.Fatal("provider 大小写/空白不应影响匹配") + } +}