Browse Source

services: 机器翻译只在服务端接得上的 provider 里挑,别随手挑中没接的那家

线上表现:文本翻译 / 同传 / 面对面全部报「未配置机器翻译服务」,
而后台 EAIMAR 下 mt_alibaba / mt_azure / mt_volcano 三家都启用着、凭据也都填了。

根因:resolveTranslator 挑选时没有任何优先级,谁先从 postgres 返回就用谁,
这次挑中了 mt_volcano,而服务端只实现了阿里一家,于是落进「provider 不是
alibaba 就返回 nil」的分支。错误信息指向「没配置」,会把人引去后台反复检查
明明填好的凭据;而且它取决于数据库返回顺序,可能自己好、也可能自己坏。

- 新增 mtProviderPriority 白名单:只在真正接了的 provider 里挑,并给出稳定优先级。
- 挑选抽成纯函数 pickMTSvc(可脱离 postgres 测),被跳过的未接入服务打一条点名 warn。
- 缓存命中不再硬编码返回 "alibaba",改为记住实际 provider。
- model_translate_test.go 5 个用例:未接入的要跳过、全不可用返回 nil、
  停用/非 MT 类别不参与、应用行优先、provider 大小写归一。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 4 weeks ago
parent
commit
263c72ad31
  1. 104
      apps/services/modules/user/model_translate.go
  2. 86
      apps/services/modules/user/model_translate_test.go

104
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>)优先于全局行(app_name=''),
// 只取启用的、类别含 comm.SvcCatMT 的服务。
// 作用域口径与 resolveThirdSvcs 一致:应用行(app_name=<app>)优先于全局行(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
}

86
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 大小写/空白不应影响匹配")
}
}
Loading…
Cancel
Save