diff --git a/apps/client/assets/images/devices/Smartcar.png b/apps/client/assets/images/devices/EaiCar.png similarity index 100% rename from apps/client/assets/images/devices/Smartcar.png rename to apps/client/assets/images/devices/EaiCar.png diff --git a/apps/client/assets/images/devices/Smartcar_icon.png b/apps/client/assets/images/devices/EaiCar_icon.png similarity index 100% rename from apps/client/assets/images/devices/Smartcar_icon.png rename to apps/client/assets/images/devices/EaiCar_icon.png diff --git a/apps/client/assets/images/devices/Echomeet.png b/apps/client/assets/images/devices/Echomeet.png deleted file mode 100644 index 14a3203b..00000000 Binary files a/apps/client/assets/images/devices/Echomeet.png and /dev/null differ diff --git a/apps/client/assets/images/devices/Echomeet_icon.png b/apps/client/assets/images/devices/Echomeet_icon.png deleted file mode 100644 index c0620f7c..00000000 Binary files a/apps/client/assets/images/devices/Echomeet_icon.png and /dev/null differ diff --git a/apps/services/comm/lang.go b/apps/services/comm/lang.go index 28de8a60..145c7e74 100644 --- a/apps/services/comm/lang.go +++ b/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, "语种拼写错误") +} diff --git a/apps/services/comm/lang_test.go b/apps/services/comm/lang_test.go index f138283a..7d79609b 100644 --- a/apps/services/comm/lang_test.go +++ b/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) + } + } +} diff --git a/apps/services/comm/ossconf.go b/apps/services/comm/ossconf.go index 0dfe4ca6..ec16d441 100644 --- a/apps/services/comm/ossconf.go +++ b/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_name='')。 +// 应用行(app_name=) 优先于全局行(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 +} diff --git a/apps/services/comm/ossconf_fallback_test.go b/apps/services/comm/ossconf_fallback_test.go new file mode 100644 index 00000000..a3fef913 --- /dev/null +++ b/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("桶不同必须判为分歧——随手挑一个会把文件传进别人的桶里") + } +} diff --git a/apps/services/modules/echomeet/providers.go b/apps/services/modules/echomeet/providers.go index 8f9bffe7..bd19c7b2 100644 --- a/apps/services/modules/echomeet/providers.go +++ b/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() diff --git a/apps/services/modules/echomeet/tasks.go b/apps/services/modules/echomeet/tasks.go index e39d6226..e05e7e5b 100644 --- a/apps/services/modules/echomeet/tasks.go +++ b/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,21 +189,40 @@ func (this *tasksComp) SubmitTranscribeTask(task *pb.DBEchoMeetRecord) (err erro this.module.model.saverecord(task) return err } - 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), - CallbackData: fmt.Sprintf("%d", task.Id), - }) - if err != nil { - this.module.Errorf("SubmitTranscribeTask id:%d 提交失败 err:%v", task.Id, err) - task.State = pb.DBEchoMeetRecordState_TranscribeFail - this.module.model.saverecord(task) - return err + // ⚠️ 提交失败要顺着编排里的优先级换下一家再试,不能一家失败就判死。 + // 后台把识别服务配成多家、给了 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: callbackURL, + CallbackData: fmt.Sprintf("%d", task.Id), + }) + 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) diff --git a/apps/services/modules/user/api_translate.go b/apps/services/modules/user/api_translate.go index d127aab8..9a83573c 100644 --- a/apps/services/modules/user/api_translate.go +++ b/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 } diff --git a/apps/services/modules/user/model_translate.go b/apps/services/modules/user/model_translate.go index 479c0fa9..d0b8d4b2 100644 --- a/apps/services/modules/user/model_translate.go +++ b/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 +}