Browse Source

services: 语音纪要 ASR 按优先级自动回退;翻译区分「语种不支持」与瞬时失败;OSS 配置在作用域归一后可兜底定位

- echomeet: SubmitTranscribeTask 首选 ASR 失败时按 Priority 依次尝试其它启用的识别服务,
  并把 AsrSvcId 写回任务,回调才能找到对的服务;原先一次失败就 TranscribeFail
- user_translate: 阿里 10005/10033(语向不支持/语种拼写错)不再当成瞬时错误,
  直接回「该语言暂不支持翻译」;挪威语 nb-NO/nn-NO/no-NO 映射到 no
- model_translate: 「跳过未接入 MT 服务」的告警去重,不再刷满日志
- ossconf: appName 为空时按指纹唯一匹配(作用域归一后全局行已不存在)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
main
Rodger-Wang 4 weeks ago
parent
commit
3709aa6e3b
  1. 0
      apps/client/assets/images/devices/EaiCar.png
  2. 0
      apps/client/assets/images/devices/EaiCar_icon.png
  3. BIN
      apps/client/assets/images/devices/Echomeet.png
  4. BIN
      apps/client/assets/images/devices/Echomeet_icon.png
  5. 24
      apps/services/comm/lang.go
  6. 58
      apps/services/comm/lang_test.go
  7. 70
      apps/services/comm/ossconf.go
  8. 34
      apps/services/comm/ossconf_fallback_test.go
  9. 27
      apps/services/modules/echomeet/providers.go
  10. 39
      apps/services/modules/echomeet/tasks.go
  11. 11
      apps/services/modules/user/api_translate.go
  12. 20
      apps/services/modules/user/model_translate.go

0
apps/client/assets/images/devices/Smartcar.png → apps/client/assets/images/devices/EaiCar.png

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 370 KiB

0
apps/client/assets/images/devices/Smartcar_icon.png → apps/client/assets/images/devices/EaiCar_icon.png

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

BIN
apps/client/assets/images/devices/Echomeet.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

BIN
apps/client/assets/images/devices/Echomeet_icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

24
apps/services/comm/lang.go

@ -31,6 +31,11 @@ var bcp47ToShort = map[string]string{
"nl-NL": "nl",
"it-IT": "it",
"uk-UA": "uk",
// ⚠️ 挪威语:客户端按 Azure 的语音码传 nb-NO,取主标签会得到 nb,
// 而阿里云机器翻译认的是 **no**(2026-09-15 真机实测:zh→no / no→zh / en→no 全通,
// 传 nb 一律回 `10033 语种拼写错误`)。这类"两家用不同 ISO 变体"的语种必须显式列表,
// 靠取主标签的兜底救不了。
"nb-NO": "no", "nn-NO": "no", "no-NO": "no",
}
// BCP47ToShortLang 把 BCP-47 语言码转成短 ISO 码(阿里云机器翻译用)。
@ -51,3 +56,22 @@ func BCP47ToShortLang(code string) string {
}
return strings.ToLower(c)
}
// IsLangUnsupportedErr 判断服务商返回的错误是不是「这个语种/语向不支持」。
//
// 阿里云机器翻译的两个码(2026-09-15 实测确认语义):
//
// 10005 translate from source to target not support —— 码认识,但这条语向不通(如乌克兰语)
// 10033 语种拼写错误 —— 这个语种码根本不在它的支持表里(如克罗地亚语,hr/hrv/cro 都不认)
//
// 这类错误**重试无用**,调用方应当直接告诉用户换一个语言,而不是「请稍后再试」。
func IsLangUnsupportedErr(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "code=10005") ||
strings.Contains(s, "code=10033") ||
strings.Contains(s, "not support") ||
strings.Contains(s, "语种拼写错误")
}

58
apps/services/comm/lang_test.go

@ -1,19 +1,29 @@
package comm
import "testing"
import (
"errors"
"testing"
)
// 语言码传错阿里云不会报错,只会返回空或原文——排查极费劲,所以用测试钉住。
func TestBCP47ToShortLang(t *testing.T) {
// 2026-09-15 真机实测:客户端 39 个语种里只有 3 个翻不出来,三种不同原因。
// 这组用例把「码怎么归一」钉死,避免又出现「阿里报语种拼写错误、查半天才发现是码错了」。
func TestBCP47ToShortLangProviderCodes(t *testing.T) {
cases := map[string]string{
"zh-CN": "zh", "zh-TW": "zh", "zh-HK": "zh",
"en-US": "en", "ja-JP": "ja", "ko-KR": "ko",
"es-MX": "es", "pt-BR": "pt", "fil-PH": "fil",
"en": "en", // 已经是短码,原样
"zh": "zh",
"EN-US": "en", // 大小写不敏感(表未命中时走主子标签并小写)
"xx-YY": "xx", // 未收录的地区变体:取主语言,别变空串
"": "", // 空进空出
" en ": "en", // 去空白
// ⚠️ 挪威语:客户端按 Azure 语音码传 nb-NO,而阿里认的是 no。
// 取主标签会得到 nb → 阿里回 10033 语种拼写错误。
"nb-NO": "no",
"nn-NO": "no",
// 表里显式收录的
"uk-UA": "uk",
"zh-CN": "zh",
"zh-TW": "zh",
// 没收录的走取主标签兜底
"hr-HR": "hr",
"sv-SE": "sv",
"el-GR": "el",
// 已经是短码的原样返回
"en": "en",
"": "",
}
for in, want := range cases {
if got := BCP47ToShortLang(in); got != want {
@ -21,3 +31,27 @@ func TestBCP47ToShortLang(t *testing.T) {
}
}
}
// 「语种不支持」重试一万次也没用,必须和「这次调用失败了」分开,
// 否则用户看到「请稍后再试」会一直点。
func TestIsLangUnsupportedErr(t *testing.T) {
unsupported := []error{
errors.New("translate failed: code=10005 msg=translate from source to target not support"),
errors.New("translate failed: code=10033 msg=语种拼写错误"),
}
for _, e := range unsupported {
if !IsLangUnsupportedErr(e) {
t.Errorf("应判为「语种不支持」: %v", e)
}
}
transient := []error{
errors.New("translate request: dial tcp: i/o timeout"),
errors.New("translate failed: code=Throttling.User msg=flow control"),
nil,
}
for _, e := range transient {
if IsLangUnsupportedErr(e) {
t.Errorf("不该判为「语种不支持」(这些重试有意义): %v", e)
}
}
}

70
apps/services/comm/ossconf.go

@ -32,7 +32,7 @@ const OSSUploadRoot = "EAIMAR"
// 和 App 上传的录音会落到不同的桶里:
// 1. 【系统配置 → 对象存储】(sys_service_config, category=storage, provider=aliyun_oss);
// 2. 该表无有效配置时回退【第三方服务配置 → 存储】(svc_config, provider=aliyun_oss),
// 应用行(app_name=<app>) 优先于全局行(app_name='')。
// 应用行(app_name=<app>) 优先于全局行(app_name=”)。
//
// encKey 为 ${FIELD_ENCRYPT_KEY}——console 与各业务服务本就必须配同一把,
// 加密字段(access_key_id/access_key_secret)用它解密。
@ -50,10 +50,28 @@ func LoadAliyunOSSConf(appName, encKey string) (*AliyunOSSConf, error) {
}
cfg := find(appName)
if cfg == nil && appName != "" {
cfg = find("") // 应用无专属配置则回退全局默认
cfg = find("") // 应用无专属配置则回退全局默认(作用域归一后已不再产生全局行)
}
if cfg == nil {
return nil, fmt.Errorf("未配置阿里云 OSS:请在【第三方服务配置 → 存储】新增并启用「阿里云 OSS」服务")
// 兜底:后台传固件/产品图是**平台级**操作,请求未必带 X-App-Id
// (产品管理是超管页面,本来就不按应用筛),于是 appName 为空;
// 而 2026-09-14 的作用域归一把 svc_config 全部分配到各应用之后,
// 全局行(app_name='')**不再存在**,上面那条回退就永远落空了 ——
// 表现是后台一上传就报「未配置阿里云 OSS」,而配置明明填得好好的。
//
// 只在**全库启用的 aliyun_oss 都指向同一个桶**时才兜底:各应用配了不同的桶时
// 随手挑一个会把文件传进别人的桶里,那比报错更糟,所以那种情况仍然报错,
// 并在文案里说清楚要带 X-App-Id。
var ambiguous bool
cfg, ambiguous = findUnambiguousOSS(encKey)
if ambiguous {
return nil, fmt.Errorf("有多个指向不同桶的阿里云 OSS 配置,无法确定用哪个:" +
"请在【系统配置 → 对象存储】启用平台级 OSS,或让该请求带上 X-App-Id")
}
}
if cfg == nil {
return nil, fmt.Errorf("未配置阿里云 OSS:请在【第三方服务配置 → 存储】新增并启用「阿里云 OSS」服务," +
"或在【系统配置 → 对象存储】启用平台级配置")
}
get := func(key string) string {
for _, f := range cfg.Fields {
@ -146,3 +164,49 @@ func (oc *AliyunOSSConf) PublicURL(signedURL, key string) string {
}
return signedURL
}
// findUnambiguousOSS 在没有应用作用域时兜底取一份 OSS 配置。
//
// 只有当所有启用的 aliyun_oss 配置**指向同一个桶**(endpoint + bucket + access_key_id
// 三者都一致)时才返回它;存在分歧时返回 ambiguous=true,由调用方报错。
// 判据刻意用解密后的 access_key_id 而不是密文:同一份密钥在不同行里的密文不同
// (AES-GCM 每次加密的 nonce 不同),按密文比会把一致的判成分歧。
func findUnambiguousOSS(encKey string) (cfg *ThirdSvcConfig, ambiguous bool) {
rows := make([]*ThirdSvcConfig, 0)
if err := postgres.Find(TableSvcConfig, &rows,
"provider=? AND enable=?", AliyunOSSProvider, true); err != nil || len(rows) == 0 {
return nil, false
}
first := ossFingerprint(rows[0], encKey)
for _, r := range rows[1:] {
if ossFingerprint(r, encKey) != first {
return nil, true
}
}
return rows[0], false
}
// ossFingerprint 一份 OSS 配置的身份:endpoint + bucket + access_key_id。
//
// ⚠️ 用**解密后**的 access_key_id 比,不能比密文:AES-GCM 每次加密的 nonce 不同,
// 同一把密钥在不同行里的密文也不同,按密文比会把一致的配置判成分歧。
func ossFingerprint(c *ThirdSvcConfig, encKey string) string {
var ep, bk, ak string
for _, f := range c.Fields {
v := f.DefValue
if f.Encrypted && v != "" {
if plain, derr := Decrypt(encKey, v); derr == nil {
v = plain
}
}
switch f.Key {
case "endpoint":
ep = strings.TrimSpace(v)
case "bucket":
bk = strings.TrimSpace(v)
case "access_key_id":
ak = strings.TrimSpace(v)
}
}
return ep + "|" + bk + "|" + ak
}

34
apps/services/comm/ossconf_fallback_test.go

@ -0,0 +1,34 @@
package comm
import "testing"
// 2026-09-14 作用域归一把 svc_config 全部分配到各应用、全局行(app_name='')不再产生之后,
// 后台传固件这条**不带 X-App-Id** 的路径就失去了兜底,一上传就报「未配置阿里云 OSS」,
// 而配置明明填得好好的。兜底的判据是「所有启用的配置是否指向同一个桶」。
func ossRow(app, ep, bucket, ak string) *ThirdSvcConfig {
return &ThirdSvcConfig{
AppName: app,
Id: "storage_aliyun_oss",
Provider: AliyunOSSProvider,
Enable: true,
Fields: []SvcField{
{Key: "endpoint", DefValue: ep},
{Key: "bucket", DefValue: bucket},
{Key: "access_key_id", DefValue: ak},
},
}
}
// fingerprint 的行为通过这组用例间接固定:同桶可兜底、异桶必须报错。
func TestOSSFingerprintAgreement(t *testing.T) {
a := ossRow("EAIMAR", "https://oss-cn-shanghai.aliyuncs.com", "ymaioss", "AK1")
b := ossRow("deepGlass", "https://oss-cn-shanghai.aliyuncs.com", "ymaioss", "AK1")
c := ossRow("other", "https://oss-cn-shanghai.aliyuncs.com", "另一个桶", "AK1")
if ossFingerprint(a, "") != ossFingerprint(b, "") {
t.Fatal("同一套 endpoint/bucket/ak 必须判为一致,否则线上两个应用配同一个桶也会被判成分歧")
}
if ossFingerprint(a, "") == ossFingerprint(c, "") {
t.Fatal("桶不同必须判为分歧——随手挑一个会把文件传进别人的桶里")
}
}

27
apps/services/modules/echomeet/providers.go

@ -242,6 +242,33 @@ func (this *providersComp) PickASR(language string) (svcId string, t Transcriber
return "", nil, "", errOrchNotConfigured("识别(支持语言 " + language + " 的)")
}
// NextASR 识别选路的**备选**:在 [PickASR] 的同一份候选里,跳过已经试过的那些,
// 按 Priority 升序取下一个支持该语言的。
//
// ⚠️ 没有这条,后台配多家识别服务就是白配:提交失败会直接把记录置成 TranscribeFail,
// 第二家一次都不会被碰。2026-09-15 线上正是如此——asrfile_alibaba 的 API Key 失效
// (阿里回 InvalidApiKey),而优先级排在后面的 asrfile_azure 配置完好,
// 却眼睁睁看着所有转写任务全失败。
func (this *providersComp) NextASR(language string, tried map[string]bool) (svcId string, t Transcriber, callbackURL string, err error) {
this.mu.RLock()
defer this.mu.RUnlock()
for _, r := range this.snap.ASR {
if tried[r.SvcId] {
continue
}
svc := this.snap.Services[r.SvcId]
inst := this.transcribers[r.SvcId]
if svc == nil || inst == nil {
continue
}
if !svcSupportsLanguage(svc.Languages, language) {
continue
}
return r.SvcId, inst, svc.CallbackURL, nil
}
return "", nil, "", errOrchNotConfigured("识别(支持语言 " + language + " 的备选)")
}
// PickMT 翻译选路:按后台 Priority 升序(服务配了 languages 则过滤,未配不限)。
func (this *providersComp) PickMT(language string) (svcId string, t Translator, err error) {
this.mu.RLock()

39
apps/services/modules/echomeet/tasks.go

@ -2,6 +2,12 @@ package echomeet
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"yunyan/comm"
"yunyan/lego/core"
"yunyan/lego/core/cbase"
@ -11,12 +17,6 @@ import (
redissys "yunyan/lego/sys/redis"
"yunyan/pb"
"yunyan/utils"
"encoding/json"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/panjf2000/ants/v2"
"github.com/redis/go-redis/v9"
@ -189,22 +189,41 @@ func (this *tasksComp) SubmitTranscribeTask(task *pb.DBEchoMeetRecord) (err erro
this.module.model.saverecord(task)
return err
}
result, err := transcriber.Submit(context.Background(), SubmitRequest{
// ⚠️ 提交失败要顺着编排里的优先级换下一家再试,不能一家失败就判死。
// 后台把识别服务配成多家、给了 Priority,本意就是「这家不行换那家」;
// 而在此之前第一家失败就直接 TranscribeFail,第二家一次都不会被碰。
// 2026-09-15 线上:asrfile_alibaba 的 API Key 失效(阿里回 InvalidApiKey),
// 配置完好的 asrfile_azure 排在它后面,所有转写却全军覆没。
tried := map[string]bool{task.AsrSvcId: true}
callbackURL := this.module.providers.ASRCallbackURL(task.AsrSvcId)
var result *SubmitResult
for {
result, err = transcriber.Submit(context.Background(), SubmitRequest{
Uid: task.Uid,
AudioURL: task.Audiourl,
Language: task.Formlanguage,
EnableSpeaker: task.Isdistinguishspeaker,
Seconds: task.Seconds,
SizeBytes: task.Size,
CallbackURL: this.module.providers.ASRCallbackURL(task.AsrSvcId),
CallbackURL: callbackURL,
CallbackData: fmt.Sprintf("%d", task.Id),
})
if err != nil {
this.module.Errorf("SubmitTranscribeTask id:%d 提交失败 err:%v", task.Id, err)
if err == nil {
break
}
this.module.Errorf("SubmitTranscribeTask id:%d svc:%s 提交失败 err:%v", task.Id, task.AsrSvcId, err)
nextId, nextInst, nextCb, nerr := this.module.providers.NextASR(task.Formlanguage, tried)
if nerr != nil {
// 没有别的候选了:这才是真失败
task.State = pb.DBEchoMeetRecordState_TranscribeFail
this.module.model.saverecord(task)
return err
}
this.module.Warnf("SubmitTranscribeTask id:%d 换用备选识别服务 %s → %s", task.Id, task.AsrSvcId, nextId)
tried[nextId] = true
task.AsrSvcId = nextId // 回调/轮询要按实际用的那家来找,必须落到记录上
transcriber, callbackURL = nextInst, nextCb
}
if result.Done { // 同步完成(字节 flash):直接走完成收尾
this.finishSyncTranscribe(task, result.Contexts)
return

11
apps/services/modules/user/api_translate.go

@ -70,6 +70,17 @@ func (this *apiComp) Translate(session comm.IUserSession, req *pb.UserTranslateR
log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "svc", Value: svcId},
log.Field{Key: "from", Value: from}, log.Field{Key: "to", Value: to},
log.Field{Key: "err", Value: err.Error()})
// ⚠️ 「这个语种服务商压根不支持」和「这次调用失败了」要分开说:
// 前者重试一万次也没用,报「请稍后再试」只会让用户一直点。
// 2026-09-15 实测:乌克兰语(uk) 阿里所有方向都回 10005、克罗地亚语(hr) 回 10033,
// 而客户端的语言表是按 Azure 语音语种列的,比阿里翻译能覆盖的多出这几个。
if comm.IsLangUnsupportedErr(err) {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_TranslateProviderError,
Message: "该语言暂不支持翻译,请换一个目标语言",
}
return
}
errdata = &pb.ErrorData{Code: pb.ErrorCode_TranslateProviderError, Message: "翻译失败,请稍后再试"}
return
}

20
apps/services/modules/user/model_translate.go

@ -90,6 +90,9 @@ type modelTranslateComp struct {
cachedId string
cachedProvider string
cachedAt time.Time
// 上一次报告过的「未接入的 MT 服务」,用于日志去重,见 noteSkipped
lastSkipped string
}
func (this *modelTranslateComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) (err error) {
@ -124,7 +127,9 @@ func (this *modelTranslateComp) resolveTranslator() (svcId, provider string, t a
}
picked, skipped := pickMTSvc(svcs, app)
if len(skipped) > 0 {
// ⚠️ 这条只在「跳过的集合变了」时打一次。翻译是高频调用(同传每句一次),
// 无条件打会把服务器日志刷满,真正的错误反而被埋掉。
if len(skipped) > 0 && this.noteSkipped(skipped) {
// 「后台启用了、服务端没接」不是错误,但一定要能看见——它是「凭据明明填了
// 却报未配置」的唯一线索。
this.module.Warn("resolveTranslator: 跳过服务端未接入的 MT 服务",
@ -173,3 +178,16 @@ func pickedIdOf(s *comm.ThirdSvcConfig) string {
}
return s.Id
}
// noteSkipped 记录「上次报告过的未接入 MT 服务」,内容没变就不再重复打日志。
// 返回 true 表示这次值得打一条。
func (this *modelTranslateComp) noteSkipped(skipped []string) bool {
key := strings.Join(skipped, ",")
this.mu.Lock()
defer this.mu.Unlock()
if this.lastSkipped == key {
return false
}
this.lastSkipped = key
return true
}

Loading…
Cancel
Save