package user import ( "os" "strings" "sync" "time" "yunyan/comm" "yunyan/lego/core" "yunyan/lego/core/cbase" "yunyan/lego/sys/log" "yunyan/lego/sys/postgres" alitranslate "yunyan/sys/aliyun/translate" ) // 实时机器翻译(同声传译 / 面对面翻译)的服务解析。 // // 与实名认证同一套路子:翻译服务配在后台「第三方服务配置」(类别 comm.SvcCatMT=3), // 存 console 共享库 postgres,凭据在服务端解密使用,**不下发客户端**。 // // 为什么放服务端而不是像以前那样客户端直连: // 以前客户端用火山翻译,AK/SK 是通过 AppConfig.env **明文下发给每一个客户端**的, // 任何人抓个包或反编译就能拿到云账号凭据。改成服务端代调后凭据不再出网关。 // mtCacheTTL 翻译客户端的缓存时长。 // // 同传是流式高频调用,每句都去查一次库、重建一次 SDK 客户端太浪费; // 但也不能永久缓存——后台改了配置得能生效,所以给个短 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 cachedProvider string cachedAt time.Time } func (this *modelTranslateComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) (err error) { this.ModuleCompBase.Init(service, module, comp, opt) this.module = module.(*User) return } // resolveTranslator 取本应用可用的机器翻译服务。 // // 作用域口径与 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, provider, t = this.cachedId, this.cachedProvider, this.cached this.mu.RUnlock() return svcId, provider, t, nil } this.mu.RUnlock() app := comm.AppName() svcs := make([]*comm.ThirdSvcConfig, 0) if err = postgres.Find(comm.TableSvcConfig, &svcs, "(app_name=? OR app_name='')", app); err != nil { if err == postgres.ErrNoDocuments { err = nil } else { return } } 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 } fields, ferr := comm.ResolveSvcPlainFields(picked, nil, os.Getenv("FIELD_ENCRYPT_KEY")) if ferr != nil { return picked.Id, picked.Provider, nil, ferr } // 目前只实现了阿里云。接别家时在这里加分支,并同步 mtProviderPriority—— // 只加一处的话:只加表 → 走到这里返回 nil 仍报「未配置」;只加分支 → 上面过滤时 // 就被当成未接入跳过了,分支根本执行不到。 if mtNormProvider(picked.Provider) != "alibaba" { return picked.Id, picked.Provider, nil, nil } opts := []alitranslate.Option{ alitranslate.SetAccessKeyId(strings.TrimSpace(fields["access_key_id"])), alitranslate.SetAccessKeySecret(strings.TrimSpace(fields["access_key_secret"])), } if r := strings.TrimSpace(fields["region"]); r != "" { opts = append(opts, alitranslate.SetRegion(r)) } sys, serr := alitranslate.NewSys(opts...) if serr != nil { return picked.Id, picked.Provider, nil, serr } this.mu.Lock() 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 }