diff --git a/apps/proto/echomeet/echomeet_db.proto b/apps/proto/echomeet/echomeet_db.proto index 3ba3f508..8e72cbb2 100644 --- a/apps/proto/echomeet/echomeet_db.proto +++ b/apps/proto/echomeet/echomeet_db.proto @@ -72,7 +72,6 @@ message DBEchoMeetRecord { int64 lastquerytime = 27; //@go_tags(`gorm:"lastquerytime" json:"lastquerytime"`) 上一次主动查询第三方服务的 unix 秒,用于限制查询频率 string remark = 28; //@go_tags(`gorm:"remark" json:"remark"`) 用户备注(创建记录时填写,参与AI总结) string imageurls = 29; //@go_tags(`gorm:"imageurls" json:"imageurls"`) 注释图片url列表(JSON数组字符串,编码方式同original),作为多模态内容参与AI总结 - string summary_model = 30; //@go_tags(`gorm:"summary_model" json:"summary_model"`) 总结所用文本大模型条目id(对应 echomeet_svc_config),空=按优先级默认 } message ContextStruct{ diff --git a/apps/proto/echomeet/echomeet_msg.proto b/apps/proto/echomeet/echomeet_msg.proto index 4c143be5..220c396d 100644 --- a/apps/proto/echomeet/echomeet_msg.proto +++ b/apps/proto/echomeet/echomeet_msg.proto @@ -112,12 +112,11 @@ message EchomeetStartTaskReq{ bool isdistinguishspeaker = 4; //@go_tags(`json:"isdistinguishspeaker"`) //是否区分说话人 uint64 templateid = 5; //@go_tags(`json:"templateid"`) //会议记录模版索引id string tid = 6; //@go_tags(`json:"tid"`) //会议记录模板tid - TranscribeService service_type = 7; //@go_tags(`json:"service_type"`) //转写服务类型,留空默认 Auto(识别现按后台优先级+语言自动选择) - string summary_model = 8; //@go_tags(`json:"summary_model"`) //总结所用文本大模型条目id(echomeet_getsummarymodels 返回),留空=按优先级默认 + TranscribeService service_type = 7; //@go_tags(`json:"service_type"`) //转写服务类型,留空默认 Auto } message EchomeetStartTaskResp{ - + } @@ -142,26 +141,10 @@ message EchomeetSummaryReq { string tid = 6; //@go_tags(`json:"tid"`) //会议记录模板tid string tolanguage = 7; //@go_tags(`json:"tolanguage"`) 语言 TranscribeService service_type = 8; //@go_tags(`json:"service_type"`) //转写服务类型(用于翻译/总结路由),留空沿用记录已有 - string summary_model = 9; //@go_tags(`json:"summary_model"`) //总结所用文本大模型条目id(echomeet_getsummarymodels 返回),留空沿用记录已有 } message EchomeetSummaryResp{ - -} - -//总结文本大模型条目(客户端选择用) -message SummaryModelInfo { - string id = 1; //@go_tags(`json:"id"`) //条目id,回传给 starttask/summary 的 summary_model - string name = 2; //@go_tags(`json:"name"`) //展示名 - string provider = 3; //@go_tags(`json:"provider"`) //底层服务商 doubao/openai/gemini - bool is_default = 4; //@go_tags(`json:"is_default"`) //是否为默认(优先级最高) -} - -//拉取可选的总结文本大模型清单 -message EchomeetGetSummaryModelsReq{ -} -message EchomeetGetSummaryModelsResp{ - repeated SummaryModelInfo models = 1; //@go_tags(`json:"models"`) //可选总结模型清单(按优先级) + } //已阅会议记录 diff --git a/apps/services/comm/log b/apps/services/comm/log new file mode 100644 index 00000000..971698ca --- /dev/null +++ b/apps/services/comm/log @@ -0,0 +1 @@ +2026/07/09 15:12:59.319 ERROR comm/moduleconfig.go:200 [ModuleConfig] 密钥字段解密失败,已跳过该字段(保留默认值) module=wechatpay key=PrivateKeyPath err=GCM 解密失败: cipher: message authentication failed —— 检查本服务 ${FIELD_ENCRYPT_KEY} 是否与 console 完全一致 diff --git a/apps/services/comm/moduleconfig.go b/apps/services/comm/moduleconfig.go index ab597841..723102c5 100644 --- a/apps/services/comm/moduleconfig.go +++ b/apps/services/comm/moduleconfig.go @@ -186,6 +186,26 @@ func ModuleFieldIsSecret(module, key string) bool { // // 按模块粒度处理:某模块在库里有行 → 覆盖 Sys(改配置需重启才生效);库里无该模块 → 把当前 yaml 值 seed 进库 // (source 语义由存在与否表达;密钥字段加密落库),Sys 保持 yaml 原值不动。best-effort:seed 单条失败即返回错误由调用方降级。 +// applyDBValues 把库里某模块的配置行应用到对应的 Sys 配置段 sec(密钥解密、按 yaml 原值类型还原)。 +// +// **fail-closed**:密钥字段解密失败时跳过该字段,保留 sec 里的 yaml 默认值, +// 绝不把密文当明文写进配置。历史事故:密文被沿用后当成微信商户私钥的文件路径去 open, +// 触发库里的 log.Fatal → home 进程退出 → 5-in-1 容器整体 crash loop → 全站 502。 +func applyDBValues(sec map[string]interface{}, rows []*AppModuleConfig, encKey string) { + for _, r := range rows { + val := r.Value + if r.Encrypted && val != "" { + plain, e := Decrypt(encKey, val) + if e != nil { + log.Errorf("[ModuleConfig] 密钥字段解密失败,已跳过该字段(保留默认值) module=%s key=%s err=%v —— 检查本服务 ${FIELD_ENCRYPT_KEY} 是否与 console 完全一致", r.Module, r.Key, e) + continue + } + val = plain + } + sec[r.Key] = coerceVal(val, sec[r.Key]) + } +} + func LoadOrSeedModuleConfig(db mysql.ISys, encKey string, sys map[string]map[string]interface{}) error { if db == nil { return nil @@ -211,20 +231,7 @@ func LoadOrSeedModuleConfig(db mysql.ISys, encKey string, sys map[string]map[str sec = map[string]interface{}{} sys[def.SysKey] = sec } - for _, r := range existing { - val := r.Value - if r.Encrypted && val != "" { - if plain, e := Decrypt(encKey, val); e == nil { - val = plain - } else { - // 仍沿用密文(保持既有行为),但必须留痕:否则密文会被当明文送去第三方 - // (如把密文当 Resend API Key 发出 → 401 API key is invalid), - // 而 console 后台密钥字段脱敏显示"已配置",几乎无从排查。 - log.Errorf("[ModuleConfig] 密钥字段解密失败 module=%s key=%s err=%v —— 检查本服务 ${FIELD_ENCRYPT_KEY} 是否与 console 完全一致", r.Module, r.Key, e) - } - } - sec[r.Key] = coerceVal(val, sec[r.Key]) - } + applyDBValues(sec, existing, encKey) continue } // 库无该模块:把当前 yaml 值 seed 进库(仅 seed yaml 里确有的字段;yaml 无此段则跳过)。 diff --git a/apps/services/comm/moduleconfig_failclosed_test.go b/apps/services/comm/moduleconfig_failclosed_test.go new file mode 100644 index 00000000..4d6d2c98 --- /dev/null +++ b/apps/services/comm/moduleconfig_failclosed_test.go @@ -0,0 +1,64 @@ +package comm + +import ( + "os" + "testing" + + "yunyan/lego/sys/log" +) + +// applyDBValues 在解密失败时会 log.Errorf;lego 的 log 子系统若未初始化会 nil 解引用。 +func TestMain(m *testing.M) { + _ = log.OnInit(nil) + os.Exit(m.Run()) +} + +// 事故回归:FIELD_ENCRYPT_KEY 与 console 不一致时,密钥字段解密失败。 +// 此时**绝不能**把密文当明文写进 Sys 配置——历史上密文被当成微信商户私钥的文件路径去 open, +// 触发 log.Fatal 让 home 退出,5-in-1 容器整体 crash loop,全站 502。 +func TestApplyDBValues_DecryptFailure_DoesNotLeakCiphertext(t *testing.T) { + const ciphertext = "HgIdj0m/9aV5CqoKQtj7Gi7yfnaCylNbGYztHF1fVKtwtfIM3UrD5jHP4khP6AI=" // 线上真实密文样本 + sec := map[string]interface{}{ + "PrivateKeyPath": "/app/certs/apiclient_key.pem", // yaml 默认值 + } + rows := []*AppModuleConfig{ + {Module: "wechatpay", Key: "PrivateKeyPath", Value: ciphertext, Encrypted: true}, + } + + applyDBValues(sec, rows, "wrong-key-not-matching-console") + + got, _ := sec["PrivateKeyPath"].(string) + if got == ciphertext { + t.Fatal("解密失败后密文被当明文写进配置——这正是导致全站 502 的 fail-open 行为") + } + if got != "/app/certs/apiclient_key.pem" { + t.Errorf("解密失败应保留 yaml 默认值,实际=%q", got) + } +} + +// 解密成功时正常覆盖。 +func TestApplyDBValues_DecryptSuccess_Overrides(t *testing.T) { + const key = "test-key-consistent-with-console" + ct, err := Encrypt(key, "real-secret-value") + if err != nil { + t.Fatalf("Encrypt err: %v", err) + } + sec := map[string]interface{}{"ApiKey": "yaml-default"} + rows := []*AppModuleConfig{{Module: "sms", Key: "ApiKey", Value: ct, Encrypted: true}} + + applyDBValues(sec, rows, key) + + if got, _ := sec["ApiKey"].(string); got != "real-secret-value" { + t.Errorf("解密成功应覆盖为明文,实际=%q", got) + } +} + +// 非加密字段照常覆盖,且按 yaml 原值类型还原。 +func TestApplyDBValues_PlainField(t *testing.T) { + sec := map[string]interface{}{"Enabled": false} + rows := []*AppModuleConfig{{Module: "sms", Key: "Enabled", Value: "true", Encrypted: false}} + applyDBValues(sec, rows, "") + if got, _ := sec["Enabled"].(bool); !got { + t.Errorf("非加密字段应按原值类型还原为 bool true,实际=%#v", sec["Enabled"]) + } +} diff --git a/apps/services/modules/echomeet/CLAUDE.md b/apps/services/modules/echomeet/CLAUDE.md index 6658af99..3f777bbf 100644 --- a/apps/services/modules/echomeet/CLAUDE.md +++ b/apps/services/modules/echomeet/CLAUDE.md @@ -8,30 +8,24 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `echomeet` 是「会议记录」模块:上传音频 → **转写(Transcribe)** → **翻译(Translate)** → **AI 总结(Summary)**,产出 `overview`(短总结)+ `summary`(按模板的完整纪要)。由 `home` 服务装载运行([services/home/main.go](../../services/home/main.go)),不是独立服务。HTTP 接口经反射注册为 `echomeet_<方法名小写>`,前缀路由示例 `/api/home/echomeet_addrecord`。 -## 核心设计:识别/翻译/总结三类各自按优先级选服务(后台配置驱动) +## 核心设计:一会一服务商,三段同源 -> 旧设计是「一会一服务商,三段同源」(`ResolveServiceType` 定一个服务商三步同走)。现已改为**三类各自独立按优先级选择**,配置全部搬进管理后台。旧的 `ResolveServiceType` 仅作兜底保留。 +每条会议记录在 [providers.go](providers.go) 的 `ResolveServiceType` 解析出**唯一服务商**,转写/翻译/总结三步都走它,结果记在 `record.ServiceType`。解析优先级: + +1. 请求显式 `service_type` → +2. 模块配置 `DefaultService` → +3. 兜底按源语言:字节支持该语言走字节,否则阿里。 四套服务商,每套三件套(见对应 `provider_*.go`): | ServiceType | 转写 | 翻译 | 总结(Summarizer) | |---|---|---|---| | ByteDance | `sys/bytedance/audomodel` | 火山翻译 | 豆包 `sys/doubao` | -| Ali | DashScope `sys/aliyun/filetrans`(paraformer-v2,**不支持西语等**) | 阿里 MT | 通义千问(`sys/openai` 兼容端点)| +| Ali | DashScope `sys/aliyun/filetrans` | 阿里 MT | 通义千问(`sys/openai` 兼容端点)| | Google | Cloud Speech | Cloud Translation | Gemini `sys/google/gemini` | | Microsoft | Azure Speech | Azure Translator | Azure OpenAI(`sys/openai` 兼容端点)| -三类能力抽象为 `Transcriber` / `Translator` / `Summarizer` 接口(providers.go)。**新的选择/配置机制在 [svcconfig.go](svcconfig.go)**: - -- 配置表 `echomeet_svc_config`(结构见 [comm/echomeetsvc.go](../../comm/echomeetsvc.go)):识别/翻译/总结三类,每类多条服务、按 `Priority` 升序;识别条目带 `SupportLangs`;总结条目=具体文本大模型。**存应用业务库、全局一份**(照 `app_module_config` 路子),密钥 AES 落库。 -- 启动(`providersComp.Init` → `loadOrSeedSvcConfig`):建表;库空则把 yaml 四套服务商 seed 成行(**阿里识别的 SupportLangs seed 成 paraformer-v2 真实支持集,不含西语**);然后用库值(凭据来自库)重建各 provider,总结按条目 `NewSys` 建实例(一个服务商可多模型)。 -- 选择: - - `selectASR(lang)`:按识别优先级取第一个支持该语言的服务(`SupportLangs` 非空以其为准,否则回退 `Transcriber.SupportsLanguage`)——**修复了西语被强路由到阿里的问题**。存 `record.ServiceType`。 - - `selectMT(from,to)`:按翻译优先级独立选。 - - 总结按 `record.SummaryModel`(客户端在 starttask/summary 传 `summary_model`,`echomeet_getsummarymodels` 拉清单)→ `GetSummarizerById`,空则默认(优先级最高)。 -- 后台 CRUD 在 console([modules/console/api_echomeetsvc.go](../console/api_echomeetsvc.go),路由 `/console/api/echomeetsvc/*`,经 `getServiceDB` 写业务库),前端页 [admin/pages/meetingsvc.vue](../../../admin/app/pages/meetingsvc.vue)。**改配置需重启 home 生效**。 - -新增服务商 = 写 `provider_xxx.go` 实现三接口 + 在 `providersComp.Init` 注册进三张 map + 在 `EchoSvcCatalog` 补字段模板 + seed 映射。 +三类能力抽象为 `Transcriber` / `Translator` / `Summarizer` 接口(providers.go),运行期 `GetTranscriber/GetTranslator/GetSummarizer(svcType, language)` 按 ServiceType 路由。新增服务商 = 写一个 `provider_xxx.go` 实现三接口 + 在 `providersComp.Init` 注册进三张 map。 ## 组件与异步流水线 diff --git a/apps/services/modules/echomeet/api_getsummarymodels.go b/apps/services/modules/echomeet/api_getsummarymodels.go deleted file mode 100644 index 7a4311da..00000000 --- a/apps/services/modules/echomeet/api_getsummarymodels.go +++ /dev/null @@ -1,30 +0,0 @@ -package echomeet - -import ( - "yunyan/comm" - "yunyan/pb" -) - -// @Summary 获取可选的总结文本大模型清单 -// @Description 返回后台配置的、按优先级排序的启用总结模型条目,供客户端在发起转写/重新总结时选择(summary_model)。 -// @Tags Echomeet -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param user body pb.EchomeetGetSummaryModelsReq true "请求数据" -// @Success 200 {object} comm.HttpResult{data=pb.EchomeetGetSummaryModelsResp} "响应数据" -// @Router /api/home/echomeet_getsummarymodels [post] -func (this *apiComp) GetSummaryModels(session comm.IUserSession, req *pb.EchomeetGetSummaryModelsReq) (resp *pb.EchomeetGetSummaryModelsResp, errdata *pb.ErrorData) { - models := this.module.providers.ListSummaryModels() - out := make([]*pb.SummaryModelInfo, 0, len(models)) - for i, m := range models { - out = append(out, &pb.SummaryModelInfo{ - Id: m.Id, - Name: m.Name, - Provider: m.Provider, - IsDefault: i == 0, - }) - } - resp = &pb.EchomeetGetSummaryModelsResp{Models: out} - return -} diff --git a/apps/services/modules/echomeet/api_starttask.go b/apps/services/modules/echomeet/api_starttask.go index 950c2e17..f69db21b 100644 --- a/apps/services/modules/echomeet/api_starttask.go +++ b/apps/services/modules/echomeet/api_starttask.go @@ -106,18 +106,11 @@ func (this *apiComp) StartTask(session comm.IUserSession, req *pb.EchomeetStartT model.Isdistinguishspeaker = req.Isdistinguishspeaker model.Formlanguage = req.Formlanguage model.Tolanguage = req.Tolanguage - // 选定识别服务:请求显式指定优先;否则按「后台配置的识别优先级 + 支持语言」自动选择 - // (跳过不支持源语言的服务,修复西语被强路由到不支持西语的阿里的问题)。翻译在 TranslateProcess 内按优先级独立选择。 - if req.ServiceType != pb.TranscribeService_TranscribeService_Auto && req.ServiceType != pb.TranscribeService_TranscribeService_Unknown { - model.ServiceType = this.module.providers.ResolveServiceType(req.ServiceType, model.Formlanguage) - } else { - model.ServiceType = this.module.providers.selectASR(model.Formlanguage) - } - // 总结所用文本大模型:客户端选择优先,留空则用后台配置的默认(优先级最高的启用条目)。 - model.SummaryModel = req.SummaryModel - if model.SummaryModel == "" { - model.SummaryModel = this.module.providers.DefaultSummaryId() - } + // 选定服务商:请求显式指定优先,其次配置默认,最后按语言自动路由; + // 再叠加「该服务商的转写引擎是否支持此源语言」校验,不支持则换到支持的那家 + // (否则会把音频送进不认识该语种的引擎,静默得到空转写结果)。 + // 一经确定,撰写/翻译/总结全部走同一服务商。 + model.ServiceType = this.module.providers.ResolveTranscribeService(req.ServiceType, model.Formlanguage) this.module.model.saverecord(model) this.module.Infof("StartTask id:%d uid:%s formlang:%s tolang:%s seconds:%d size:%d distinguishSpeaker:%v service:%s", diff --git a/apps/services/modules/echomeet/api_summary.go b/apps/services/modules/echomeet/api_summary.go index 17ea8904..3ee1340e 100644 --- a/apps/services/modules/echomeet/api_summary.go +++ b/apps/services/modules/echomeet/api_summary.go @@ -62,13 +62,7 @@ func (this *apiComp) Summary(session comm.IUserSession, req *pb.EchomeetSummaryR svc = model.ServiceType } model.ServiceType = this.module.providers.ResolveServiceType(svc, model.Formlanguage) - // 总结所用文本大模型:客户端显式指定则更新,否则沿用记录已有(为空再回退默认)。 - if req.SummaryModel != "" { - model.SummaryModel = req.SummaryModel - } else if model.SummaryModel == "" { - model.SummaryModel = this.module.providers.DefaultSummaryId() - } - this.module.Infof("Summary id:%d → service:%s summaryModel:%s", model.Id, ServiceTypeName(model.ServiceType), model.SummaryModel) + this.module.Infof("Summary id:%d → service:%s", model.Id, ServiceTypeName(model.ServiceType)) // 是否需要重新翻译: // - 客户端显式传入 tolanguage(重新总结操作,强制重译以保证 Translate 与 Tolanguage 一致);或 // - 目标语言变化;或 diff --git a/apps/services/modules/echomeet/fix_verify_test.go b/apps/services/modules/echomeet/fix_verify_test.go new file mode 100644 index 00000000..e1ccdd00 --- /dev/null +++ b/apps/services/modules/echomeet/fix_verify_test.go @@ -0,0 +1,89 @@ +package echomeet + +import ( + "testing" + + "yunyan/pb" +) + +// testTranscribers 构造仅含转写能力的 provider 集合(不初始化任何 SDK/凭据)。 +func testTranscribers() map[pb.TranscribeService]Transcriber { + return map[pb.TranscribeService]Transcriber{ + pb.TranscribeService_TranscribeService_ByteDance: &byteDanceTranscriber{}, + pb.TranscribeService_TranscribeService_Ali: &aliTranscriber{}, + } +} + +// 阿里转写的语言支持判定:paraformer-v2 不支持西语等,必须返回 false, +// 否则西语音频会被送进阿里、静默返回 SUCCESS_WITH_NO_VALID_FRAGMENT(看着像"音频没声音")。 +func TestAliTranscriberSupportsLanguage(t *testing.T) { + tr := &aliTranscriber{} + for _, l := range []string{"zh-CN", "zh-TW", "yue-HK", "en-US", "ja-JP", "ko-KR", "de-DE", "fr-FR", "ru-RU"} { + if !tr.SupportsLanguage(l) { + t.Errorf("paraformer-v2 应支持 %s,却返回 false", l) + } + } + // 这些语种 bcp47ToDashScope 里有映射,但 paraformer-v2 实际不支持 + for _, l := range []string{"es-ES", "es-MX", "pt-BR", "it-IT", "vi-VN", "tr-TR", "pl-PL", "nl-NL", "uk-UA", "th-TH", "ar-SA", "id-ID", "ms-MY", "fil-PH"} { + if tr.SupportsLanguage(l) { + t.Errorf("paraformer-v2 不支持 %s,却返回 true(会导致空转写)", l) + } + } + if !tr.SupportsLanguage("") { + t.Error("空语言应允许 DashScope 自动检测") + } +} + +// 字节转写支持西语,是西语会议兜底的前提。 +func TestByteDanceSupportsSpanish(t *testing.T) { + if !(&byteDanceTranscriber{}).SupportsLanguage("es-ES") { + t.Fatal("字节应支持 es-ES(西语兜底目标)") + } +} + +// 核心回归:即使 DefaultService 被固定成 Ali(线上就是这么配的), +// 西语也不能进阿里,必须改道到支持它的字节。 +func TestPickTranscribeServiceAvoidsUnsupportedLang(t *testing.T) { + trs := testTranscribers() + ali := pb.TranscribeService_TranscribeService_Ali + byteDance := pb.TranscribeService_TranscribeService_ByteDance + + cases := []struct { + lang string + resolved pb.TranscribeService // ResolveServiceType 的结果(DefaultService=Ali) + wantSvc pb.TranscribeService + wantChanged bool + }{ + {"es-ES", ali, byteDance, true}, // 西语:阿里不支持 → 改道字节(本次修复的 bug) + {"pt-BR", ali, byteDance, true}, // 葡语:同上 + {"zh-CN", ali, ali, false}, // 中文:阿里支持 → 不乱改道 + {"ru-RU", ali, ali, false}, // 俄语:阿里支持 → 维持 + {"en-US", byteDance, byteDance, false}, // 英语:字节支持 → 维持 + } + for _, c := range cases { + got, changed, supported := pickTranscribeService(trs, c.resolved, c.lang) + if got != c.wantSvc || changed != c.wantChanged { + t.Errorf("lang=%s resolved=%s: got=(%s,changed=%v) want=(%s,changed=%v)", + c.lang, ServiceTypeName(c.resolved), ServiceTypeName(got), changed, + ServiceTypeName(c.wantSvc), c.wantChanged) + } + if !supported { + t.Errorf("lang=%s: 选中的 %s 应支持该语言", c.lang, ServiceTypeName(got)) + } + } +} + +// 没有任何服务商支持该语言时:维持原选择、不改道、并明确报告 unsupported(调用方据此告警)。 +func TestPickTranscribeServiceNoneSupports(t *testing.T) { + // 只放阿里,喂一个阿里不支持的语种 → 无处可去 + trs := map[pb.TranscribeService]Transcriber{ + pb.TranscribeService_TranscribeService_Ali: &aliTranscriber{}, + } + got, changed, supported := pickTranscribeService(trs, pb.TranscribeService_TranscribeService_Ali, "es-ES") + if got != pb.TranscribeService_TranscribeService_Ali || changed { + t.Errorf("无可用兜底时应维持原选择且不改道,got=%s changed=%v", ServiceTypeName(got), changed) + } + if supported { + t.Error("应报告 unsupported=false,让调用方告警") + } +} diff --git a/apps/services/modules/echomeet/provider_ali.go b/apps/services/modules/echomeet/provider_ali.go index b5c8d4fd..ac0b8adc 100644 --- a/apps/services/modules/echomeet/provider_ali.go +++ b/apps/services/modules/echomeet/provider_ali.go @@ -8,6 +8,7 @@ import ( "yunyan/sys/aliyun/translate" "yunyan/sys/openai" "fmt" + "strings" ) // newAliProviders 初始化阿里系单元服务(转写=DashScope 录音文件转写、翻译=阿里翻译、 @@ -38,9 +39,22 @@ func (this *aliTranscriber) Name() pb.TranscribeService { return pb.TranscribeService_TranscribeService_Ali } -// 阿里 DashScope 对常见语种有 hints 支持;不支持时也允许由 DashScope 自动检测,故默认返回 true -func (this *aliTranscriber) SupportsLanguage(_ string) bool { - return true +// dashScopeParaformerV2Langs 是 filetrans 所用模型 paraformer-v2 **实际**支持的语种(DashScope 语言码)。 +// +// 坑:core.go 的 bcp47ToDashScope 里还映射了 es/pt/it/vi/tr/pl/nl/uk/th/ar/id/ms/fil, +// 但 paraformer-v2 并不支持它们——把这些语种的音频送进去,引擎识别不出任何内容, +// 转写查询会返回 SUCCESS_WITH_NO_VALID_FRAGMENT(看起来像"音频没声音",实则是模型不认识该语种)。 +// 若将来把 filetrans.go 的 Model 换成 Fun-ASR / Qwen-ASR(支持更多语种),需同步放开这里。 +var dashScopeParaformerV2Langs = map[string]bool{ + "zh": true, "yue": true, "en": true, "ja": true, "ko": true, + "de": true, "fr": true, "ru": true, +} + +func (this *aliTranscriber) SupportsLanguage(bcp47 string) bool { + if strings.TrimSpace(bcp47) == "" { + return true // 空 = 不指定 hints,交给 DashScope 自动检测 + } + return dashScopeParaformerV2Langs[toDashScopeLang(bcp47)] } func (this *aliTranscriber) Submit(_ context.Context, req SubmitRequest) (*SubmitResult, error) { diff --git a/apps/services/modules/echomeet/providers.go b/apps/services/modules/echomeet/providers.go index 71c8c9e1..096bc3bc 100644 --- a/apps/services/modules/echomeet/providers.go +++ b/apps/services/modules/echomeet/providers.go @@ -2,7 +2,6 @@ package echomeet import ( "context" - "yunyan/comm" "yunyan/lego/core" "yunyan/lego/core/cbase" "yunyan/pb" @@ -21,14 +20,6 @@ type providersComp struct { transcribers map[pb.TranscribeService]Transcriber translators map[pb.TranscribeService]Translator summarizers map[pb.TranscribeService]Summarizer - - // 会议记录服务配置(DB 驱动,启动时从 yaml seed 进应用业务库,改配置重启生效)。 - // 识别/翻译各维护一份按优先级排序的启用清单,识别叠加「支持语言」过滤。见 svcconfig.go。 - svcLoaded bool - asrOrder []*comm.EchoSvcConfig // 识别:enabled,priority 升序 - mtOrder []*comm.EchoSvcConfig // 翻译:enabled,priority 升序 - llmOrder []*comm.EchoSvcConfig // 总结:enabled,priority 升序(第一个为默认) - summarizersById map[string]Summarizer // 总结模型实例,key=条目 id(客户端按 id 选择) } // 任务状态常量,对齐底层 sys 包 @@ -126,10 +117,6 @@ func (this *providersComp) Init(service core.IService, module core.IModule, comp pb.TranscribeService_TranscribeService_Microsoft: msSummarizer, } this.module.Infof("echomeet providers 初始化完成 DefaultService=%s", opt.DefaultService) - - // 会议记录服务配置:从 yaml seed 进应用业务库(幂等),并加载识别/翻译的优先级清单。 - // best-effort:失败不阻断启动,选择逻辑会回退到旧的 ResolveServiceType。 - this.loadOrSeedSvcConfig(opt) return } @@ -170,6 +157,49 @@ func (this *providersComp) ResolveServiceType(svcType pb.TranscribeService, lang return pb.TranscribeService_TranscribeService_Ali } +// transcribeFallbackOrder 转写兜底顺序:字节的 SupportsLanguage 有真实语言表,最可信,排首位。 +var transcribeFallbackOrder = []pb.TranscribeService{ + pb.TranscribeService_TranscribeService_ByteDance, + pb.TranscribeService_TranscribeService_Ali, + pb.TranscribeService_TranscribeService_Microsoft, + pb.TranscribeService_TranscribeService_Google, +} + +// pickTranscribeService 纯函数:若 resolved 的转写引擎不支持 language,则按 transcribeFallbackOrder +// 选第一个支持的服务商。返回 (选中的服务商, 是否改道, 选中的服务商是否真的支持该语言)。 +func pickTranscribeService(transcribers map[pb.TranscribeService]Transcriber, resolved pb.TranscribeService, language string) (svc pb.TranscribeService, changed bool, supported bool) { + if t, ok := transcribers[resolved]; ok && t != nil && t.SupportsLanguage(language) { + return resolved, false, true + } + for _, s := range transcribeFallbackOrder { + if s == resolved { + continue + } + if t, ok := transcribers[s]; ok && t != nil && t.SupportsLanguage(language) { + return s, true, true + } + } + return resolved, false, false +} + +// ResolveTranscribeService 在 ResolveServiceType 之上叠加「所选服务商是否支持该源语言」的校验, +// 不支持时换到第一个支持的服务商。 +// +// 为什么需要:ResolveServiceType 只看请求/配置/字节语言表,一旦 DefaultService 固定成某家, +// 就会把引擎不认识的语种也硬塞进去。典型事故是西语被送进阿里 paraformer-v2(该模型不支持西语), +// 引擎静默返回 SUCCESS_WITH_NO_VALID_FRAGMENT,表现得像「音频没声音」,极难排查。 +// 只作用于**转写**选路;翻译/总结仍沿用记录已定的 ServiceType(三段同源不变)。 +func (this *providersComp) ResolveTranscribeService(svcType pb.TranscribeService, language string) pb.TranscribeService { + resolved := this.ResolveServiceType(svcType, language) + picked, changed, supported := pickTranscribeService(this.transcribers, resolved, language) + if changed { + this.module.Warnf("转写服务 %s 不支持语言 %s,改用 %s", ServiceTypeName(resolved), language, ServiceTypeName(picked)) + } else if !supported { + this.module.Warnf("无转写服务支持语言 %s,维持 %s(转写可能失败)", language, ServiceTypeName(picked)) + } + return picked +} + // ==================== 工厂方法 ==================== // GetTranscriber 取转写 provider;svcType=Auto/Unknown 时先 ResolveServiceType。 diff --git a/apps/services/modules/echomeet/svcconfig.go b/apps/services/modules/echomeet/svcconfig.go deleted file mode 100644 index d863775e..00000000 --- a/apps/services/modules/echomeet/svcconfig.go +++ /dev/null @@ -1,466 +0,0 @@ -package echomeet - -import ( - "yunyan/comm" - "yunyan/lego/sys/log" - "yunyan/lego/sys/mysql" - "yunyan/pb" - "yunyan/sys/doubao" - "yunyan/sys/google/gemini" - "yunyan/sys/openai" - "fmt" - "os" - "sort" - "strings" - "time" -) - -// 会议记录服务配置的运行时消费(数据模型见 comm/echomeetsvc.go)。 -// -// 启动流程(providersComp.Init 末尾调用 loadOrSeedSvcConfig): -// 1. 建表(应用业务库,全局一份); -// 2. 库空 → 把当前 yaml(Options) 的四套服务商 seed 成识别/翻译/总结行(密钥加密落库); -// 3. 加载识别/翻译两类的启用清单,按 priority 升序,供选择逻辑使用。 -// -// 选择逻辑: -// - 识别 selectASR(lang):按优先级取第一个「支持该语言」的服务(支持语言以库里 SupportLangs 为准, -// 为空则回退各 Transcriber.SupportsLanguage)——这修复了「西语被强路由到不支持西语的阿里 paraformer-v2」。 -// - 翻译 selectMT(from,to):按优先级取第一个启用的服务。 -// -// 凭据托管:seed 会把 yaml 里的 AK/SK/Token 等一并写入库(密钥字段 AES 落库), -// 便于后台面板管理;SDK 侧改读库值将在后续阶段接入(当前仍由 yaml 初始化,行为不变)。 - -// paraformerV2Langs 阿里 DashScope paraformer-v2 录音文件识别真实支持的语种(BCP-47)。 -// 依据阿里官方文档:中文(及方言)/英文/日语/韩语/德语/法语/俄语——**不含西班牙语等**。 -// 作为阿里识别行 SupportLangs 的 seed 默认值;用户可在后台面板增删。 -var paraformerV2Langs = []string{ - "zh-CN", "zh-TW", "zh-HK", "yue-HK", - "en-US", "en-GB", "ja-JP", "ko-KR", - "de-DE", "fr-FR", "ru-RU", "ru-UA", -} - -// providerToEnum 把库里的 provider 字符串(识别/翻译类)映射回 TranscribeService 枚举。 -func providerToEnum(provider string) pb.TranscribeService { - switch provider { - case "bytedance": - return pb.TranscribeService_TranscribeService_ByteDance - case "ali": - return pb.TranscribeService_TranscribeService_Ali - case "google": - return pb.TranscribeService_TranscribeService_Google - case "microsoft": - return pb.TranscribeService_TranscribeService_Microsoft - default: - return pb.TranscribeService_TranscribeService_Unknown - } -} - -// loadOrSeedSvcConfig 建表 + 库空则 seed + 加载识别/翻译优先级清单。best-effort,失败仅记录日志。 -func (this *providersComp) loadOrSeedSvcConfig(opt *Options) { - db := mysql.GetSys() - if db == nil { - return - } - if err := comm.EnsureEchoSvcTable(db); err != nil { - this.module.Errorf("echomeet svcconfig 建表失败 err:%v", err) - return - } - rows, err := comm.LoadEchoSvcConfigs(db) - if err != nil { - this.module.Errorf("echomeet svcconfig 读取失败 err:%v", err) - return - } - if len(rows) == 0 { - if rows, err = this.seedSvcConfig(db, opt); err != nil { - this.module.Errorf("echomeet svcconfig seed 失败 err:%v", err) - return - } - this.module.Infof("echomeet svcconfig 首次 seed 完成 rows:%d", len(rows)) - } - - // 统一把 Values 解密为明文供运行时消费(seed 返回的本就是明文,Decrypt 对明文会失败并保留原值,安全)。 - encKey := os.Getenv("FIELD_ENCRYPT_KEY") - for _, r := range rows { - r.Values = comm.DecryptEchoValues(encKey, r.Category, r.Provider, r.Values) - } - - // 分类归集 + 优先级排序(仅启用项)。 - asr := make([]*comm.EchoSvcConfig, 0) - mt := make([]*comm.EchoSvcConfig, 0) - llm := make([]*comm.EchoSvcConfig, 0) - for _, r := range rows { - if !r.Enabled { - continue - } - switch r.Category { - case comm.EchoSvcCatASR: - asr = append(asr, r) - case comm.EchoSvcCatMT: - mt = append(mt, r) - case comm.EchoSvcCatLLM: - llm = append(llm, r) - } - } - sort.SliceStable(asr, func(i, j int) bool { return asr[i].Priority < asr[j].Priority }) - sort.SliceStable(mt, func(i, j int) bool { return mt[i].Priority < mt[j].Priority }) - sort.SliceStable(llm, func(i, j int) bool { return llm[i].Priority < llm[j].Priority }) - this.asrOrder = asr - this.mtOrder = mt - this.llmOrder = llm - - // 用库值(凭据来自库)重建各服务商的转写/翻译/总结实例;总结按条目建实例支持一商多模型。 - this.buildProvidersFromDB(rows, opt) - - this.svcLoaded = true - this.module.Infof("echomeet svcconfig 加载完成 识别:%d 翻译:%d 总结:%d", len(asr), len(mt), len(llm)) -} - -// buildProvidersFromDB 用库里的(已解密)配置重建 provider: -// - 识别/翻译:逐服务商用库值重新初始化底层 sys(凭据托管到后台的落点),覆盖 enum→provider 映射;某项缺行则回退 yaml。 -// - 总结:每条启用的 LLM 行建一个模型实例(key=条目 id),客户端按 id 选择;同时按源服务商回填 enum→summarizer 保持旧调用兼容。 -func (this *providersComp) buildProvidersFromDB(rows []*comm.EchoSvcConfig, opt *Options) { - byId := make(map[string]*comm.EchoSvcConfig, len(rows)) - for _, r := range rows { - byId[r.Id] = r - } - type provSeed struct { - name string - enum pb.TranscribeService - po ProviderOptions - } - provs := []provSeed{ - {"bytedance", pb.TranscribeService_TranscribeService_ByteDance, opt.ByteDance}, - {"ali", pb.TranscribeService_TranscribeService_Ali, opt.Ali}, - {"google", pb.TranscribeService_TranscribeService_Google, opt.Google}, - {"microsoft", pb.TranscribeService_TranscribeService_Microsoft, opt.Microsoft}, - } - for _, p := range provs { - po := p.po // 缺行回退 yaml - if r := byId["asr_"+p.name]; r != nil { - po.Transcribe = toIfaceMap(r.Values) - if cb := r.Values["Callback"]; cb != "" { - po.Callback = cb - } - } - if r := byId["mt_"+p.name]; r != nil { - po.Translate = toIfaceMap(r.Values) - } - if r := byId["llm_"+p.name]; r != nil { - po.Summary = summaryFromValues(r.Provider, r.Values) - } - t, tr, sm := buildProviderSet(p.name, po) - if t != nil { - this.transcribers[p.enum] = t - } - if tr != nil { - this.translators[p.enum] = tr - } - if sm != nil { - this.summarizers[p.enum] = sm - } - } - // 总结模型实例(一个服务商可多条不同模型)。 - this.summarizersById = make(map[string]Summarizer) - for _, r := range rows { - if r.Category != comm.EchoSvcCatLLM || !r.Enabled { - continue - } - if s := buildSummarizer(r.Provider, r.Values); s != nil { - this.summarizersById[r.Id] = s - } - } -} - -// buildProviderSet 按服务商名调对应的成套初始化(从 DB 值构造的 ProviderOptions)。 -func buildProviderSet(name string, po ProviderOptions) (Transcriber, Translator, Summarizer) { - switch name { - case "bytedance": - return newByteDanceProviders(po) - case "ali": - return newAliProviders(po) - case "google": - return newGoogleProviders(po) - case "microsoft": - return newMicrosoftProviders(po) - } - return nil, nil, nil -} - -// buildSummarizer 按总结条目的 provider(doubao/openai/gemini) + 值构造一个总结模型实例。 -func buildSummarizer(provider string, v map[string]string) Summarizer { - switch provider { - case "doubao": - sys, err := doubao.NewSys(doubao.SetApikey(v["Apikey"]), doubao.SetModel(v["Model"])) - if err != nil { - log.Errorf("echomeet buildSummarizer doubao err:%v", err) - return nil - } - return &doubaoSummarizer{sys: sys} - case "openai": - sys, err := openai.NewSys(openai.SetBaseURL(v["BaseURL"]), openai.SetToken(v["Token"]), openai.SetModel(v["Model"]), openai.SetMaxfunccall(1)) - if err != nil { - log.Errorf("echomeet buildSummarizer openai err:%v", err) - return nil - } - return &aliSummarizer{sys: sys} // aliSummarizer 是通用 OpenAI 兼容端点包装 - case "gemini": - sys, err := gemini.NewSys(gemini.SetApikey(v["Apikey"]), gemini.SetModel(v["Model"]), gemini.SetEndpoint(v["Endpoint"])) - if err != nil { - log.Errorf("echomeet buildSummarizer gemini err:%v", err) - return nil - } - return &geminiSummarizer{sys: sys} - } - return nil -} - -// summaryFromValues 把库 Values 还原成 SummaryOptions(summaryToValues 的逆过程)。 -func summaryFromValues(provider string, v map[string]string) SummaryOptions { - switch provider { - case "doubao": - return SummaryOptions{Apikey: v["Apikey"], Model: v["Model"]} - case "gemini": - return SummaryOptions{Apikey: v["Apikey"], Model: v["Model"], Endpoint: v["Endpoint"]} - case "openai": - return SummaryOptions{BaseURL: v["BaseURL"], Token: v["Token"], Model: v["Model"]} - } - return SummaryOptions{} -} - -// toIfaceMap 把 map[string]string 转成底层 sys OnInit 需要的 map[string]interface{}。 -func toIfaceMap(m map[string]string) map[string]interface{} { - out := make(map[string]interface{}, len(m)) - for k, v := range m { - out[k] = v - } - return out -} - -// GetSummarizerById 按条目 id 取总结模型实例;空或未命中回退优先级最高的启用条目。 -func (this *providersComp) GetSummarizerById(id string) Summarizer { - if id != "" && this.summarizersById != nil { - if s, ok := this.summarizersById[id]; ok && s != nil { - return s - } - } - if len(this.llmOrder) > 0 && this.summarizersById != nil { - return this.summarizersById[this.llmOrder[0].Id] - } - return nil -} - -// DefaultSummaryId 返回优先级最高的启用总结条目 id(无则空)。 -func (this *providersComp) DefaultSummaryId() string { - if len(this.llmOrder) > 0 { - return this.llmOrder[0].Id - } - return "" -} - -// ListSummaryModels 返回按优先级排序的启用总结条目(供客户端选择)。 -func (this *providersComp) ListSummaryModels() []*comm.EchoSvcConfig { - return this.llmOrder -} - -// seedSvcConfig 把 yaml(Options) 的四套服务商映射为识别/翻译/总结行并落库;返回落库后的行(Values 为明文,供内存直接用)。 -func (this *providersComp) seedSvcConfig(db mysql.ISys, opt *Options) ([]*comm.EchoSvcConfig, error) { - encKey := os.Getenv("FIELD_ENCRYPT_KEY") - now := time.Now().Unix() - // provider 顺序即默认优先级(字节→阿里→Google→微软,留间隔便于后台调序)。 - type provSeed struct { - provider string - prio int32 - po ProviderOptions - } - provs := []provSeed{ - {"bytedance", 10, opt.ByteDance}, - {"ali", 20, opt.Ali}, - {"google", 30, opt.Google}, - {"microsoft", 40, opt.Microsoft}, - } - - rows := make([]*comm.EchoSvcConfig, 0, len(provs)*3) - add := func(r *comm.EchoSvcConfig) { - r.Createtime, r.Updatetime = now, now - rows = append(rows, r) - } - for _, p := range provs { - // —— 识别 —— - asrVals := mapToStrings(p.po.Transcribe) - if p.po.Callback != "" { - asrVals["Callback"] = p.po.Callback - } - add(&comm.EchoSvcConfig{ - Id: "asr_" + p.provider, Category: comm.EchoSvcCatASR, Provider: p.provider, - Name: providerDisplayName(comm.EchoSvcCatASR, p.provider), Priority: p.prio, - Enabled: hasCreds(asrVals), SupportLangs: seedSupportLangs(p.provider), Values: asrVals, - }) - // —— 翻译 —— - mtVals := mapToStrings(p.po.Translate) - add(&comm.EchoSvcConfig{ - Id: "mt_" + p.provider, Category: comm.EchoSvcCatMT, Provider: p.provider, - Name: providerDisplayName(comm.EchoSvcCatMT, p.provider), Priority: p.prio, - Enabled: hasCreds(mtVals), Values: mtVals, - }) - // —— 总结(文本大模型)—— - if llmProvider, llmVals := summaryToValues(p.provider, p.po.Summary); llmVals != nil { - add(&comm.EchoSvcConfig{ - Id: "llm_" + p.provider, Category: comm.EchoSvcCatLLM, Provider: llmProvider, - Name: summaryDisplayName(p.provider, p.po.Summary.Model), Priority: p.prio, - Enabled: p.po.Summary.Model != "", Values: llmVals, - }) - } - } - - // 落库:密钥字段加密。内存返回明文副本。 - for _, r := range rows { - enc := *r - enc.Values = comm.EncryptEchoValues(encKey, r.Category, r.Provider, r.Values) - if err := db.Insert(comm.TableEchomeetSvcConfig, &enc); err != nil { - return nil, err - } - } - return rows, nil -} - -// selectASR 按优先级 + 支持语言选识别服务;无命中回退旧的 ResolveServiceType。 -func (this *providersComp) selectASR(lang string) pb.TranscribeService { - if this.svcLoaded { - for _, r := range this.asrOrder { - if this.asrSupportsLang(r, lang) { - if svc := providerToEnum(r.Provider); svc != pb.TranscribeService_TranscribeService_Unknown { - return svc - } - } - } - } - // 回退:沿用旧的按语言自动路由(字节支持则字节,否则阿里)。 - return this.ResolveServiceType(pb.TranscribeService_TranscribeService_Auto, lang) -} - -// selectMT 按优先级选翻译服务;无命中回退到给定识别服务(保持三段同源的旧行为)。 -func (this *providersComp) selectMT(from, to string, fallback pb.TranscribeService) pb.TranscribeService { - if this.svcLoaded { - for _, r := range this.mtOrder { - if svc := providerToEnum(r.Provider); svc != pb.TranscribeService_TranscribeService_Unknown { - if _, ok := this.translators[svc]; ok { - return svc - } - } - } - } - return fallback -} - -// asrSupportsLang 判断某识别行是否支持该语言:库里 SupportLangs 非空以其为准,否则回退 Transcriber.SupportsLanguage。 -func (this *providersComp) asrSupportsLang(r *comm.EchoSvcConfig, lang string) bool { - if strings.TrimSpace(r.SupportLangs) != "" { - return langInCSV(r.SupportLangs, lang) - } - if t, ok := this.transcribers[providerToEnum(r.Provider)]; ok && t != nil { - return t.SupportsLanguage(lang) - } - return false -} - -// ==================== seed 辅助 ==================== - -// mapToStrings 把 yaml 的 map[string]interface{} 值统一转成字符串。 -func mapToStrings(m map[string]interface{}) map[string]string { - out := make(map[string]string, len(m)) - for k, v := range m { - if v == nil { - continue - } - if s, ok := v.(string); ok { - out[k] = s - } else { - out[k] = fmt.Sprintf("%v", v) - } - } - return out -} - -// summaryToValues 把结构化的 SummaryOptions 转成库 Values,并返回该总结条目的 provider(doubao/openai/gemini)。 -func summaryToValues(sourceProvider string, s SummaryOptions) (provider string, values map[string]string) { - switch sourceProvider { - case "bytedance": - return "doubao", map[string]string{"Apikey": s.Apikey, "Model": s.Model} - case "google": - return "gemini", map[string]string{"Apikey": s.Apikey, "Model": s.Model, "Endpoint": s.Endpoint} - case "ali", "microsoft": - return "openai", map[string]string{"BaseURL": s.BaseURL, "Token": s.Token, "Model": s.Model} - } - return "", nil -} - -// hasCreds 判断一组配置是否有实质凭据(任一非空即视为已配)。 -func hasCreds(vals map[string]string) bool { - for k, v := range vals { - if k == "Region" || k == "Endpoint" || k == "Callback" { - continue // 这些是环境/地址项,不算凭据 - } - if strings.TrimSpace(v) != "" { - return true - } - } - return false -} - -// seedSupportLangs 识别行的支持语言默认 seed 值。 -func seedSupportLangs(provider string) string { - switch provider { - case "ali": - // 阿里 paraformer-v2 真实支持集(不含西语等)——从根上避免西语被路由到阿里后转写空。 - return strings.Join(paraformerV2Langs, ",") - case "bytedance": - return strings.Join(byteDanceSupportLangs(), ",") - default: - return "" // Google/微软支持较广,留空表示按代码默认(SupportsLanguage=true) - } -} - -// byteDanceSupportLangs 从 bcp47ToByteDance 收集字节支持的 BCP-47 语种。 -func byteDanceSupportLangs() []string { - out := make([]string, 0, len(bcp47ToByteDance)) - for k := range bcp47ToByteDance { - if k != "" { - out = append(out, k) - } - } - sort.Strings(out) - return out -} - -// langInCSV 判断 lang 是否在逗号分隔集合内(精确匹配,或按基础子标签匹配,如 es 命中 es-ES)。 -func langInCSV(csv, lang string) bool { - if lang == "" { - return false - } - base := baseLang(lang) - for _, item := range strings.Split(csv, ",") { - item = strings.TrimSpace(item) - if item == "" { - continue - } - if strings.EqualFold(item, lang) || strings.EqualFold(baseLang(item), base) { - return true - } - } - return false -} - -func providerDisplayName(category int32, provider string) string { - if def := comm.FindEchoSvcDef(category, provider); def != nil { - return def.Name - } - return provider -} - -func summaryDisplayName(sourceProvider, model string) string { - name := providerDisplayName(comm.EchoSvcCatASR, sourceProvider) - if model != "" { - return name + "(" + model + ")" - } - return name -} diff --git a/apps/services/modules/echomeet/tasks.go b/apps/services/modules/echomeet/tasks.go index 558bf0ad..5b1a4723 100644 --- a/apps/services/modules/echomeet/tasks.go +++ b/apps/services/modules/echomeet/tasks.go @@ -32,6 +32,10 @@ type tasksComp struct { service comm.IService artshortPool *ants.PoolWithFunc //小音频转写池塘 aipool *ants.PoolWithFunc //AI总结池塘 + // finishing 记录「转写已完成、正在做收尾(翻译+提交AI)」的记录 id。 + // 客户端每 TranscribeQueryMinInterval(10s) 轮询一次,而收尾里的整篇翻译可能耗时数十秒, + // 没有这把锁时多个 getrecord 会同时进入收尾、各自把全文重译一遍,直接打爆翻译服务 QPS。 + finishing sync.Map // key: uint64 记录id, value: struct{} } func (this *tasksComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, options core.IModuleOptions) (err error) { @@ -350,9 +354,7 @@ func (this *tasksComp) TranslateProcess(rec *pb.DBEchoMeetRecord, contexts []*pb for _, item := range contexts { tests = append(tests, item.Content) } - // 翻译服务按「后台配置的翻译优先级」独立选择;无配置时回退到识别所用服务商(旧的三段同源行为)。 - mtSvc := this.module.providers.selectMT(rec.Formlanguage, rec.Tolanguage, rec.ServiceType) - translator, err := this.module.providers.GetTranslator(mtSvc, rec.Formlanguage) + translator, err := this.module.providers.GetTranslator(rec.ServiceType, rec.Formlanguage) if err != nil { this.module.Errorf("TranslateProcess id:%d 取 translator 失败 err:%v", rec.Id, err) return err @@ -360,10 +362,18 @@ func (this *tasksComp) TranslateProcess(rec *pb.DBEchoMeetRecord, contexts []*pb // 这里直接传 BCP-47,由各 provider 自行转换为其平台的语言码。 // 不要在这里调用 toTranslateLang —— 否则 provider 内部再调一次会把 "en" 当未知 fallback 成 "zh",最终发出去的是 zh→zh。 results, err = translator.Translate(context.Background(), rec.Formlanguage, rec.Tolanguage, tests) - if err != nil { - this.module.Errorf("TranslateProcess id:%d 翻译失败 service:%s err:%v", rec.Id, ServiceTypeName(translator.Name()), err) + // 判定「结果是否可用」只认长度对齐,不能只看 results==nil: + // 有的 provider(如字节)出错时用具名返回值裸 return,会带回一个长度不足的非 nil 切片。 + if err != nil && len(results) != len(tests) { + this.module.Errorf("TranslateProcess id:%d 翻译失败 service:%s got=%d want=%d err:%v", + rec.Id, ServiceTypeName(translator.Name()), len(results), len(tests), err) return err } + if err != nil { + // 部分失败但结果完整(provider 已用原文占位):记日志后继续,不让整篇译文作废。 + this.module.Warnf("TranslateProcess id:%d 部分句子翻译失败(已用原文占位) service:%s err:%v", + rec.Id, ServiceTypeName(translator.Name()), err) + } for i, item := range contexts { if i >= len(results) { this.module.Warnf("TranslateProcess id:%d 翻译结果数量不足 期望:%d 实际:%d", rec.Id, len(contexts), len(results)) @@ -404,13 +414,32 @@ func (this *tasksComp) AIProcess(task interface{}) { err2 error ) + // 总结优先用译文;翻译失败/缺失时退回原文,避免把空内容送给大模型 + // (通义/豆包会直接报 InternalError.Algo.InvalidParameter: The content field is a required field)。 + src := rec.Translate + if strings.TrimSpace(src) == "" { + src = rec.Original + if strings.TrimSpace(src) != "" { + this.module.Warnf("AIProcess id:%d 译文为空,退回用原文总结", rec.Id) + } + } contexts := make([]*pb.ContextStruct, 0) - if err := json.Unmarshal([]byte(rec.Translate), &contexts); err == nil { + if err := json.Unmarshal([]byte(src), &contexts); err == nil { for _, v := range contexts { originalText += fmt.Sprintf("[%s]:%s\n", v.Speaker, v.Content) } } else { - originalText = rec.Translate + originalText = src + } + // 转写与译文均为空(典型:音频无有效语音,转写返回空结果)——直接判失败, + // 不要拿空内容去调大模型换一个语焉不详的 400。 + if strings.TrimSpace(originalText) == "" { + this.module.Errorf("AIProcess id:%d 转写内容为空(音频可能无有效语音),跳过总结", rec.Id) + rec.State = pb.DBEchoMeetRecordState_SummarizFail + this.module.model.saverecord(rec) + procKey := this.keyAIProc() + _ = redissys.Conn().LRem(context.Background(), procKey, 1, fmt.Sprintf("%d", rec.Id)).Err() + return } // 用户在创建记录时填写的备注,与转写内容一并提交给 AI 总结。 // 用语言中立的结构化标签包裹,避免硬编码某种语言的提示词,污染多语言场景下的总结输出。 @@ -427,18 +456,14 @@ func (this *tasksComp) AIProcess(task interface{}) { _ = redissys.Conn().LRem(context.Background(), procKey, 1, fmt.Sprintf("%d", rec.Id)).Err() return } - // 总结按客户端所选文本大模型条目 id 取实例;空/未命中回退优先级最高的启用条目,再回退旧的按服务商路由。 - summarizer := this.module.providers.GetSummarizerById(rec.SummaryModel) - if summarizer == nil { - var serr error - if summarizer, serr = this.module.providers.GetSummarizer(rec.ServiceType, rec.Formlanguage); serr != nil || summarizer == nil { - this.module.Errorf("AIProcess id:%d 取 summarizer 失败 model:%s err:%v", rec.Id, rec.SummaryModel, serr) - rec.State = pb.DBEchoMeetRecordState_SummarizFail - this.module.model.saverecord(rec) - procKey := this.keyAIProc() - _ = redissys.Conn().LRem(context.Background(), procKey, 1, fmt.Sprintf("%d", rec.Id)).Err() - return - } + summarizer, err := this.module.providers.GetSummarizer(rec.ServiceType, rec.Formlanguage) + if err != nil { + this.module.Errorf("AIProcess id:%d 取 summarizer 失败 err:%v", rec.Id, err) + rec.State = pb.DBEchoMeetRecordState_SummarizFail + this.module.model.saverecord(rec) + procKey := this.keyAIProc() + _ = redissys.Conn().LRem(context.Background(), procKey, 1, fmt.Sprintf("%d", rec.Id)).Err() + return } this.module.Infof("AIProcess id:%d → service:%s", rec.Id, ServiceTypeName(summarizer.Name())) // 注释图片:用户创建记录时填写的 imageurls(JSON 数组字符串),解析后作为多模态内容随 @@ -513,6 +538,11 @@ func (this *tasksComp) PollTranscribe(rec *pb.DBEchoMeetRecord) { } switch result.Status { case StatusSuccess: + // 重入保护:本条已在收尾(翻译)中则直接返回,避免并发轮询重复翻译整篇。 + if _, busy := this.finishing.LoadOrStore(rec.Id, struct{}{}); busy { + this.module.Debugf("转写轮询 id:%d 收尾进行中,跳过", rec.Id) + return + } personnels := make(map[string]struct{}) for _, c := range result.Contexts { c.Meetingid = rec.Id @@ -526,10 +556,13 @@ func (this *tasksComp) PollTranscribe(rec *pb.DBEchoMeetRecord) { rec.Personnel += fmt.Sprintf("[%s] ", k) } rec.Original = utils.ToString(result.Contexts) - this.TranslateProcess(rec, result.Contexts) + // 先把状态推离 Transcribing 并落库:后续 getrecord 不再进入本分支(与上面的内存锁双保险)。 + rec.State = pb.DBEchoMeetRecordState_AwaitSummarizing this.module.model.saverecord(rec) - this.module.Infof("转写轮询 id:%d 转写完成,提交AI任务", rec.Id) - this.SubmitAITask(rec) + // 翻译从 HTTP 轮询路径移到后台:原来在这里同步翻几百句(~20s),把 getrecord 拖成 20s 响应, + // 且状态直到翻完才翻转,导致 10s 一次的轮询不断重入、并发重译 → 触发翻译服务限流。 + // 只传 id:调用方(getrecord)返回后仍会序列化它持有的那个 rec 指针,把同一指针交给后台协程会产生数据竞争。 + go this.finishTranscribe(rec.Id, result.Contexts) case StatusRunning: this.module.Debugf("转写轮询 id:%d 仍在处理中", rec.Id) default: @@ -538,3 +571,35 @@ func (this *tasksComp) PollTranscribe(rec *pb.DBEchoMeetRecord) { this.module.model.saverecord(rec) } } + +// finishTranscribe 转写完成后的收尾:翻译 → 落库 → 提交 AI 总结任务。 +// 在后台协程执行,不阻塞调用方(HTTP 轮询)。翻译失败不阻断流程——AIProcess 会退回用原文总结, +// 好过让整条记录卡死或拿空内容去调大模型。 +// +// 只接收 id 而非 record 指针:调用方 getrecord 会把它那个 record 指针放进 HTTP 响应去序列化, +// 后台协程若共用同一指针改 Translate/State,会与序列化产生数据竞争。这里重新从库里读一份。 +func (this *tasksComp) finishTranscribe(id uint64, contexts []*pb.ContextStruct) { + var rec *pb.DBEchoMeetRecord + defer func() { + this.finishing.Delete(id) + if r := recover(); r != nil { + // 此时状态已是 AwaitSummarizing 但 AI 任务未入队,若不置失败会永远卡住。 + this.module.Errorf("转写收尾发生panic id:%d err:%v", id, r) + if rec != nil { + rec.State = pb.DBEchoMeetRecordState_SummarizFail + this.module.model.saverecord(rec) + } + } + }() + var err error + if rec, err = this.module.model.getrecord(id); err != nil { + this.module.Errorf("转写收尾 id:%d 读取记录失败 err:%v", id, err) + return + } + if terr := this.TranslateProcess(rec, contexts); terr != nil { + this.module.Errorf("转写收尾 id:%d 翻译失败,将退回原文做总结 err:%v", id, terr) + } + this.module.model.saverecord(rec) + this.module.Infof("转写轮询 id:%d 转写完成,提交AI任务", id) + _ = this.SubmitAITask(rec) +} diff --git a/apps/services/pb/echomeet_db.pb.go b/apps/services/pb/echomeet_db.pb.go index 420f0b7e..4fd458b6 100644 --- a/apps/services/pb/echomeet_db.pb.go +++ b/apps/services/pb/echomeet_db.pb.go @@ -312,7 +312,6 @@ type DBEchoMeetRecord struct { Lastquerytime int64 `protobuf:"varint,27,opt,name=lastquerytime,proto3" json:"lastquerytime" gorm:"lastquerytime"` //@go_tags(`gorm:"lastquerytime" json:"lastquerytime"`) 上一次主动查询第三方服务的 unix 秒,用于限制查询频率 Remark string `protobuf:"bytes,28,opt,name=remark,proto3" json:"remark" gorm:"remark"` //@go_tags(`gorm:"remark" json:"remark"`) 用户备注(创建记录时填写,参与AI总结) Imageurls string `protobuf:"bytes,29,opt,name=imageurls,proto3" json:"imageurls" gorm:"imageurls"` //@go_tags(`gorm:"imageurls" json:"imageurls"`) 注释图片url列表(JSON数组字符串,编码方式同original),作为多模态内容参与AI总结 - SummaryModel string `protobuf:"bytes,30,opt,name=summary_model,json=summaryModel,proto3" json:"summary_model" gorm:"summary_model"` //@go_tags(`gorm:"summary_model" json:"summary_model"`) 总结所用文本大模型条目id(对应 echomeet_svc_config),空=按优先级默认 unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -550,13 +549,6 @@ func (x *DBEchoMeetRecord) GetImageurls() string { return "" } -func (x *DBEchoMeetRecord) GetSummaryModel() string { - if x != nil { - return x.SummaryModel - } - return "" -} - type ContextStruct struct { state protoimpl.MessageState `protogen:"open.v1"` Meetingid uint64 `protobuf:"varint,1,opt,name=Meetingid,proto3" json:"meetingid"` //@go_tags(`json:"meetingid"`) 会议id @@ -1001,7 +993,7 @@ const file_echomeet_echomeet_db_proto_rawDesc = "" + "\aoutline\x18\n" + " \x01(\tR\aoutline\x12\x1a\n" + "\btemplate\x18\v \x01(\tR\btemplate\x12\x12\n" + - "\x04sort\x18\f \x01(\x05R\x04sort\"\xa2\a\n" + + "\x04sort\x18\f \x01(\x05R\x04sort\"\xfd\x06\n" + "\x10DBEchoMeetRecord\x12\x0e\n" + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x10\n" + "\x03uid\x18\x02 \x01(\tR\x03uid\x12,\n" + @@ -1036,8 +1028,7 @@ const file_echomeet_echomeet_db_proto_rawDesc = "" + "\x0fprocessduration\x18\x1a \x01(\x03R\x0fprocessduration\x12$\n" + "\rlastquerytime\x18\x1b \x01(\x03R\rlastquerytime\x12\x16\n" + "\x06remark\x18\x1c \x01(\tR\x06remark\x12\x1c\n" + - "\timageurls\x18\x1d \x01(\tR\timageurls\x12#\n" + - "\rsummary_model\x18\x1e \x01(\tR\fsummaryModel\"\x99\x01\n" + + "\timageurls\x18\x1d \x01(\tR\timageurls\"\x99\x01\n" + "\rContextStruct\x12\x1c\n" + "\tMeetingid\x18\x01 \x01(\x04R\tMeetingid\x12\x18\n" + "\aContent\x18\x02 \x01(\tR\aContent\x12\x1c\n" + diff --git a/apps/services/pb/echomeet_msg.pb.go b/apps/services/pb/echomeet_msg.pb.go index c62f2dc6..d5193638 100644 --- a/apps/services/pb/echomeet_msg.pb.go +++ b/apps/services/pb/echomeet_msg.pb.go @@ -975,8 +975,7 @@ type EchomeetStartTaskReq struct { Isdistinguishspeaker bool `protobuf:"varint,4,opt,name=isdistinguishspeaker,proto3" json:"isdistinguishspeaker"` //@go_tags(`json:"isdistinguishspeaker"`) //是否区分说话人 Templateid uint64 `protobuf:"varint,5,opt,name=templateid,proto3" json:"templateid"` //@go_tags(`json:"templateid"`) //会议记录模版索引id Tid string `protobuf:"bytes,6,opt,name=tid,proto3" json:"tid"` //@go_tags(`json:"tid"`) //会议记录模板tid - ServiceType TranscribeService `protobuf:"varint,7,opt,name=service_type,json=serviceType,proto3,enum=TranscribeService" json:"service_type"` //@go_tags(`json:"service_type"`) //转写服务类型,留空默认 Auto(识别现按后台优先级+语言自动选择) - SummaryModel string `protobuf:"bytes,8,opt,name=summary_model,json=summaryModel,proto3" json:"summary_model"` //@go_tags(`json:"summary_model"`) //总结所用文本大模型条目id(echomeet_getsummarymodels 返回),留空=按优先级默认 + ServiceType TranscribeService `protobuf:"varint,7,opt,name=service_type,json=serviceType,proto3,enum=TranscribeService" json:"service_type"` //@go_tags(`json:"service_type"`) //转写服务类型,留空默认 Auto unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1060,13 +1059,6 @@ func (x *EchomeetStartTaskReq) GetServiceType() TranscribeService { return TranscribeService_TranscribeService_Unknown } -func (x *EchomeetStartTaskReq) GetSummaryModel() string { - if x != nil { - return x.SummaryModel - } - return "" -} - type EchomeetStartTaskResp struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -1232,7 +1224,6 @@ type EchomeetSummaryReq struct { Tid string `protobuf:"bytes,6,opt,name=tid,proto3" json:"tid"` //@go_tags(`json:"tid"`) //会议记录模板tid Tolanguage string `protobuf:"bytes,7,opt,name=tolanguage,proto3" json:"tolanguage"` //@go_tags(`json:"tolanguage"`) 语言 ServiceType TranscribeService `protobuf:"varint,8,opt,name=service_type,json=serviceType,proto3,enum=TranscribeService" json:"service_type"` //@go_tags(`json:"service_type"`) //转写服务类型(用于翻译/总结路由),留空沿用记录已有 - SummaryModel string `protobuf:"bytes,9,opt,name=summary_model,json=summaryModel,proto3" json:"summary_model"` //@go_tags(`json:"summary_model"`) //总结所用文本大模型条目id(echomeet_getsummarymodels 返回),留空沿用记录已有 unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1302,13 +1293,6 @@ func (x *EchomeetSummaryReq) GetServiceType() TranscribeService { return TranscribeService_TranscribeService_Unknown } -func (x *EchomeetSummaryReq) GetSummaryModel() string { - if x != nil { - return x.SummaryModel - } - return "" -} - type EchomeetSummaryResp struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -1345,156 +1329,6 @@ func (*EchomeetSummaryResp) Descriptor() ([]byte, []int) { return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{25} } -// 总结文本大模型条目(客户端选择用) -type SummaryModelInfo struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id"` //@go_tags(`json:"id"`) //条目id,回传给 starttask/summary 的 summary_model - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name"` //@go_tags(`json:"name"`) //展示名 - Provider string `protobuf:"bytes,3,opt,name=provider,proto3" json:"provider"` //@go_tags(`json:"provider"`) //底层服务商 doubao/openai/gemini - IsDefault bool `protobuf:"varint,4,opt,name=is_default,json=isDefault,proto3" json:"is_default"` //@go_tags(`json:"is_default"`) //是否为默认(优先级最高) - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SummaryModelInfo) Reset() { - *x = SummaryModelInfo{} - mi := &file_echomeet_echomeet_msg_proto_msgTypes[26] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SummaryModelInfo) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SummaryModelInfo) ProtoMessage() {} - -func (x *SummaryModelInfo) ProtoReflect() protoreflect.Message { - mi := &file_echomeet_echomeet_msg_proto_msgTypes[26] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SummaryModelInfo.ProtoReflect.Descriptor instead. -func (*SummaryModelInfo) Descriptor() ([]byte, []int) { - return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{26} -} - -func (x *SummaryModelInfo) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *SummaryModelInfo) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *SummaryModelInfo) GetProvider() string { - if x != nil { - return x.Provider - } - return "" -} - -func (x *SummaryModelInfo) GetIsDefault() bool { - if x != nil { - return x.IsDefault - } - return false -} - -// 拉取可选的总结文本大模型清单 -type EchomeetGetSummaryModelsReq struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EchomeetGetSummaryModelsReq) Reset() { - *x = EchomeetGetSummaryModelsReq{} - mi := &file_echomeet_echomeet_msg_proto_msgTypes[27] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EchomeetGetSummaryModelsReq) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EchomeetGetSummaryModelsReq) ProtoMessage() {} - -func (x *EchomeetGetSummaryModelsReq) ProtoReflect() protoreflect.Message { - mi := &file_echomeet_echomeet_msg_proto_msgTypes[27] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EchomeetGetSummaryModelsReq.ProtoReflect.Descriptor instead. -func (*EchomeetGetSummaryModelsReq) Descriptor() ([]byte, []int) { - return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{27} -} - -type EchomeetGetSummaryModelsResp struct { - state protoimpl.MessageState `protogen:"open.v1"` - Models []*SummaryModelInfo `protobuf:"bytes,1,rep,name=models,proto3" json:"models"` //@go_tags(`json:"models"`) //可选总结模型清单(按优先级) - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EchomeetGetSummaryModelsResp) Reset() { - *x = EchomeetGetSummaryModelsResp{} - mi := &file_echomeet_echomeet_msg_proto_msgTypes[28] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EchomeetGetSummaryModelsResp) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EchomeetGetSummaryModelsResp) ProtoMessage() {} - -func (x *EchomeetGetSummaryModelsResp) ProtoReflect() protoreflect.Message { - mi := &file_echomeet_echomeet_msg_proto_msgTypes[28] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EchomeetGetSummaryModelsResp.ProtoReflect.Descriptor instead. -func (*EchomeetGetSummaryModelsResp) Descriptor() ([]byte, []int) { - return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{28} -} - -func (x *EchomeetGetSummaryModelsResp) GetModels() []*SummaryModelInfo { - if x != nil { - return x.Models - } - return nil -} - // 已阅会议记录 type EchomeetReadRecordReq struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1505,7 +1339,7 @@ type EchomeetReadRecordReq struct { func (x *EchomeetReadRecordReq) Reset() { *x = EchomeetReadRecordReq{} - mi := &file_echomeet_echomeet_msg_proto_msgTypes[29] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1517,7 +1351,7 @@ func (x *EchomeetReadRecordReq) String() string { func (*EchomeetReadRecordReq) ProtoMessage() {} func (x *EchomeetReadRecordReq) ProtoReflect() protoreflect.Message { - mi := &file_echomeet_echomeet_msg_proto_msgTypes[29] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1530,7 +1364,7 @@ func (x *EchomeetReadRecordReq) ProtoReflect() protoreflect.Message { // Deprecated: Use EchomeetReadRecordReq.ProtoReflect.Descriptor instead. func (*EchomeetReadRecordReq) Descriptor() ([]byte, []int) { - return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{29} + return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{26} } func (x *EchomeetReadRecordReq) GetId() uint64 { @@ -1548,7 +1382,7 @@ type EchomeetReadRecordResp struct { func (x *EchomeetReadRecordResp) Reset() { *x = EchomeetReadRecordResp{} - mi := &file_echomeet_echomeet_msg_proto_msgTypes[30] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1560,7 +1394,7 @@ func (x *EchomeetReadRecordResp) String() string { func (*EchomeetReadRecordResp) ProtoMessage() {} func (x *EchomeetReadRecordResp) ProtoReflect() protoreflect.Message { - mi := &file_echomeet_echomeet_msg_proto_msgTypes[30] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1573,7 +1407,7 @@ func (x *EchomeetReadRecordResp) ProtoReflect() protoreflect.Message { // Deprecated: Use EchomeetReadRecordResp.ProtoReflect.Descriptor instead. func (*EchomeetReadRecordResp) Descriptor() ([]byte, []int) { - return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{30} + return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{27} } // 修改会议记录 @@ -1591,7 +1425,7 @@ type EchomeetModifyRecordsReq struct { func (x *EchomeetModifyRecordsReq) Reset() { *x = EchomeetModifyRecordsReq{} - mi := &file_echomeet_echomeet_msg_proto_msgTypes[31] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1603,7 +1437,7 @@ func (x *EchomeetModifyRecordsReq) String() string { func (*EchomeetModifyRecordsReq) ProtoMessage() {} func (x *EchomeetModifyRecordsReq) ProtoReflect() protoreflect.Message { - mi := &file_echomeet_echomeet_msg_proto_msgTypes[31] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1616,7 +1450,7 @@ func (x *EchomeetModifyRecordsReq) ProtoReflect() protoreflect.Message { // Deprecated: Use EchomeetModifyRecordsReq.ProtoReflect.Descriptor instead. func (*EchomeetModifyRecordsReq) Descriptor() ([]byte, []int) { - return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{31} + return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{28} } func (x *EchomeetModifyRecordsReq) GetId() uint64 { @@ -1670,7 +1504,7 @@ type EchomeetModifyRecordsResp struct { func (x *EchomeetModifyRecordsResp) Reset() { *x = EchomeetModifyRecordsResp{} - mi := &file_echomeet_echomeet_msg_proto_msgTypes[32] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1682,7 +1516,7 @@ func (x *EchomeetModifyRecordsResp) String() string { func (*EchomeetModifyRecordsResp) ProtoMessage() {} func (x *EchomeetModifyRecordsResp) ProtoReflect() protoreflect.Message { - mi := &file_echomeet_echomeet_msg_proto_msgTypes[32] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1695,7 +1529,7 @@ func (x *EchomeetModifyRecordsResp) ProtoReflect() protoreflect.Message { // Deprecated: Use EchomeetModifyRecordsResp.ProtoReflect.Descriptor instead. func (*EchomeetModifyRecordsResp) Descriptor() ([]byte, []int) { - return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{32} + return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{29} } func (x *EchomeetModifyRecordsResp) GetRecord() *DBEchoMeetRecord { @@ -1715,7 +1549,7 @@ type EchomeetDelRecordsReq struct { func (x *EchomeetDelRecordsReq) Reset() { *x = EchomeetDelRecordsReq{} - mi := &file_echomeet_echomeet_msg_proto_msgTypes[33] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1727,7 +1561,7 @@ func (x *EchomeetDelRecordsReq) String() string { func (*EchomeetDelRecordsReq) ProtoMessage() {} func (x *EchomeetDelRecordsReq) ProtoReflect() protoreflect.Message { - mi := &file_echomeet_echomeet_msg_proto_msgTypes[33] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1740,7 +1574,7 @@ func (x *EchomeetDelRecordsReq) ProtoReflect() protoreflect.Message { // Deprecated: Use EchomeetDelRecordsReq.ProtoReflect.Descriptor instead. func (*EchomeetDelRecordsReq) Descriptor() ([]byte, []int) { - return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{33} + return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{30} } func (x *EchomeetDelRecordsReq) GetIds() []uint64 { @@ -1759,7 +1593,7 @@ type EchomeetDelRecordsResp struct { func (x *EchomeetDelRecordsResp) Reset() { *x = EchomeetDelRecordsResp{} - mi := &file_echomeet_echomeet_msg_proto_msgTypes[34] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1771,7 +1605,7 @@ func (x *EchomeetDelRecordsResp) String() string { func (*EchomeetDelRecordsResp) ProtoMessage() {} func (x *EchomeetDelRecordsResp) ProtoReflect() protoreflect.Message { - mi := &file_echomeet_echomeet_msg_proto_msgTypes[34] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1784,7 +1618,7 @@ func (x *EchomeetDelRecordsResp) ProtoReflect() protoreflect.Message { // Deprecated: Use EchomeetDelRecordsResp.ProtoReflect.Descriptor instead. func (*EchomeetDelRecordsResp) Descriptor() ([]byte, []int) { - return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{34} + return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{31} } func (x *EchomeetDelRecordsResp) GetIds() []uint64 { @@ -1808,7 +1642,7 @@ type EchomeetAliCallbackReq struct { func (x *EchomeetAliCallbackReq) Reset() { *x = EchomeetAliCallbackReq{} - mi := &file_echomeet_echomeet_msg_proto_msgTypes[35] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1820,7 +1654,7 @@ func (x *EchomeetAliCallbackReq) String() string { func (*EchomeetAliCallbackReq) ProtoMessage() {} func (x *EchomeetAliCallbackReq) ProtoReflect() protoreflect.Message { - mi := &file_echomeet_echomeet_msg_proto_msgTypes[35] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1833,7 +1667,7 @@ func (x *EchomeetAliCallbackReq) ProtoReflect() protoreflect.Message { // Deprecated: Use EchomeetAliCallbackReq.ProtoReflect.Descriptor instead. func (*EchomeetAliCallbackReq) Descriptor() ([]byte, []int) { - return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{35} + return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{32} } func (x *EchomeetAliCallbackReq) GetStatusCode() int32 { @@ -1880,7 +1714,7 @@ type EchomeetAliCallbackResult struct { func (x *EchomeetAliCallbackResult) Reset() { *x = EchomeetAliCallbackResult{} - mi := &file_echomeet_echomeet_msg_proto_msgTypes[36] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1892,7 +1726,7 @@ func (x *EchomeetAliCallbackResult) String() string { func (*EchomeetAliCallbackResult) ProtoMessage() {} func (x *EchomeetAliCallbackResult) ProtoReflect() protoreflect.Message { - mi := &file_echomeet_echomeet_msg_proto_msgTypes[36] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1905,7 +1739,7 @@ func (x *EchomeetAliCallbackResult) ProtoReflect() protoreflect.Message { // Deprecated: Use EchomeetAliCallbackResult.ProtoReflect.Descriptor instead. func (*EchomeetAliCallbackResult) Descriptor() ([]byte, []int) { - return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{36} + return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{33} } func (x *EchomeetAliCallbackResult) GetSentences() []*EchomeetAliSentence { @@ -1928,7 +1762,7 @@ type EchomeetAliSentence struct { func (x *EchomeetAliSentence) Reset() { *x = EchomeetAliSentence{} - mi := &file_echomeet_echomeet_msg_proto_msgTypes[37] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1940,7 +1774,7 @@ func (x *EchomeetAliSentence) String() string { func (*EchomeetAliSentence) ProtoMessage() {} func (x *EchomeetAliSentence) ProtoReflect() protoreflect.Message { - mi := &file_echomeet_echomeet_msg_proto_msgTypes[37] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1953,7 +1787,7 @@ func (x *EchomeetAliSentence) ProtoReflect() protoreflect.Message { // Deprecated: Use EchomeetAliSentence.ProtoReflect.Descriptor instead. func (*EchomeetAliSentence) Descriptor() ([]byte, []int) { - return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{37} + return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{34} } func (x *EchomeetAliSentence) GetText() string { @@ -1999,7 +1833,7 @@ type EchomeetAliCallbackResp struct { func (x *EchomeetAliCallbackResp) Reset() { *x = EchomeetAliCallbackResp{} - mi := &file_echomeet_echomeet_msg_proto_msgTypes[38] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2011,7 +1845,7 @@ func (x *EchomeetAliCallbackResp) String() string { func (*EchomeetAliCallbackResp) ProtoMessage() {} func (x *EchomeetAliCallbackResp) ProtoReflect() protoreflect.Message { - mi := &file_echomeet_echomeet_msg_proto_msgTypes[38] + mi := &file_echomeet_echomeet_msg_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2024,7 +1858,7 @@ func (x *EchomeetAliCallbackResp) ProtoReflect() protoreflect.Message { // Deprecated: Use EchomeetAliCallbackResp.ProtoReflect.Descriptor instead. func (*EchomeetAliCallbackResp) Descriptor() ([]byte, []int) { - return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{38} + return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{35} } var File_echomeet_echomeet_msg_proto protoreflect.FileDescriptor @@ -2078,7 +1912,7 @@ const file_echomeet_echomeet_msg_proto_rawDesc = "" + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x1a\n" + "\baudiourl\x18\x02 \x01(\tR\baudiourl\"A\n" + "\x14EchomeetUpRecordResp\x12)\n" + - "\x06record\x18\x01 \x01(\v2\x11.DBEchoMeetRecordR\x06record\"\xac\x02\n" + + "\x06record\x18\x01 \x01(\v2\x11.DBEchoMeetRecordR\x06record\"\x87\x02\n" + "\x14EchomeetStartTaskReq\x12\x0e\n" + "\x02id\x18\x01 \x01(\x04R\x02id\x12\"\n" + "\fformlanguage\x18\x02 \x01(\tR\fformlanguage\x12\x1e\n" + @@ -2090,8 +1924,7 @@ const file_echomeet_echomeet_msg_proto_rawDesc = "" + "templateid\x18\x05 \x01(\x04R\n" + "templateid\x12\x10\n" + "\x03tid\x18\x06 \x01(\tR\x03tid\x125\n" + - "\fservice_type\x18\a \x01(\x0e2\x12.TranscribeServiceR\vserviceType\x12#\n" + - "\rsummary_model\x18\b \x01(\tR\fsummaryModel\"\x17\n" + + "\fservice_type\x18\a \x01(\x0e2\x12.TranscribeServiceR\vserviceType\"\x17\n" + "\x15EchomeetStartTaskResp\"\xcf\x01\n" + "\x13EchomeetCallbackReq\x12\x12\n" + "\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" + @@ -2102,7 +1935,7 @@ const file_echomeet_echomeet_msg_proto_rawDesc = "" + "audio_info\x18\x05 \x01(\v2\n" + ".AudioInfoR\taudioInfo\x12#\n" + "\rcallback_data\x18\x06 \x01(\tR\fcallbackData\"\x16\n" + - "\x14EchomeetCallbackResp\"\xd2\x01\n" + + "\x14EchomeetCallbackResp\"\xad\x01\n" + "\x12EchomeetSummaryReq\x12\x0e\n" + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x1e\n" + "\n" + @@ -2112,18 +1945,8 @@ const file_echomeet_echomeet_msg_proto_rawDesc = "" + "\n" + "tolanguage\x18\a \x01(\tR\n" + "tolanguage\x125\n" + - "\fservice_type\x18\b \x01(\x0e2\x12.TranscribeServiceR\vserviceType\x12#\n" + - "\rsummary_model\x18\t \x01(\tR\fsummaryModel\"\x15\n" + - "\x13EchomeetSummaryResp\"q\n" + - "\x10SummaryModelInfo\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + - "\x04name\x18\x02 \x01(\tR\x04name\x12\x1a\n" + - "\bprovider\x18\x03 \x01(\tR\bprovider\x12\x1d\n" + - "\n" + - "is_default\x18\x04 \x01(\bR\tisDefault\"\x1d\n" + - "\x1bEchomeetGetSummaryModelsReq\"I\n" + - "\x1cEchomeetGetSummaryModelsResp\x12)\n" + - "\x06models\x18\x01 \x03(\v2\x11.SummaryModelInfoR\x06models\"'\n" + + "\fservice_type\x18\b \x01(\x0e2\x12.TranscribeServiceR\vserviceType\"\x15\n" + + "\x13EchomeetSummaryResp\"'\n" + "\x15EchomeetReadRecordReq\x12\x0e\n" + "\x02id\x18\x01 \x01(\x04R\x02id\"\x18\n" + "\x16EchomeetReadRecordResp\"\xb0\x01\n" + @@ -2172,80 +1995,76 @@ func file_echomeet_echomeet_msg_proto_rawDescGZIP() []byte { return file_echomeet_echomeet_msg_proto_rawDescData } -var file_echomeet_echomeet_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 39) +var file_echomeet_echomeet_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 36) var file_echomeet_echomeet_msg_proto_goTypes = []any{ - (*EchomeetGetTemplatesReq)(nil), // 0: EchomeetGetTemplatesReq - (*EchomeetGetTemplatesResp)(nil), // 1: EchomeetGetTemplatesResp - (*EchomeetAddTemplateReq)(nil), // 2: EchomeetAddTemplateReq - (*EchomeetAddTemplateResp)(nil), // 3: EchomeetAddTemplateResp - (*EchomeetUpdateTemplateReq)(nil), // 4: EchomeetUpdateTemplateReq - (*EchomeetUpdateTemplateResp)(nil), // 5: EchomeetUpdateTemplateResp - (*EchomeetDeleteTemplateReq)(nil), // 6: EchomeetDeleteTemplateReq - (*EchomeetDeleteTemplateResp)(nil), // 7: EchomeetDeleteTemplateResp - (*EchomeetGetAllRecordsReq)(nil), // 8: EchomeetGetAllRecordsReq - (*EchomeetGetAllRecordsResp)(nil), // 9: EchomeetGetAllRecordsResp - (*EchomeetGetRecordsReq)(nil), // 10: EchomeetGetRecordsReq - (*EchomeetGetRecordsResp)(nil), // 11: EchomeetGetRecordsResp - (*EchomeetGetRecordReq)(nil), // 12: EchomeetGetRecordReq - (*EchomeetGetRecordResp)(nil), // 13: EchomeetGetRecordResp - (*EchomeetPutRecordsReq)(nil), // 14: EchomeetPutRecordsReq - (*EchomeetPutRecordsResp)(nil), // 15: EchomeetPutRecordsResp - (*EchomeetAddRecordReq)(nil), // 16: EchomeetAddRecordReq - (*EchomeetAddRecordResp)(nil), // 17: EchomeetAddRecordResp - (*EchomeetUpRecordReq)(nil), // 18: EchomeetUpRecordReq - (*EchomeetUpRecordResp)(nil), // 19: EchomeetUpRecordResp - (*EchomeetStartTaskReq)(nil), // 20: EchomeetStartTaskReq - (*EchomeetStartTaskResp)(nil), // 21: EchomeetStartTaskResp - (*EchomeetCallbackReq)(nil), // 22: EchomeetCallbackReq - (*EchomeetCallbackResp)(nil), // 23: EchomeetCallbackResp - (*EchomeetSummaryReq)(nil), // 24: EchomeetSummaryReq - (*EchomeetSummaryResp)(nil), // 25: EchomeetSummaryResp - (*SummaryModelInfo)(nil), // 26: SummaryModelInfo - (*EchomeetGetSummaryModelsReq)(nil), // 27: EchomeetGetSummaryModelsReq - (*EchomeetGetSummaryModelsResp)(nil), // 28: EchomeetGetSummaryModelsResp - (*EchomeetReadRecordReq)(nil), // 29: EchomeetReadRecordReq - (*EchomeetReadRecordResp)(nil), // 30: EchomeetReadRecordResp - (*EchomeetModifyRecordsReq)(nil), // 31: EchomeetModifyRecordsReq - (*EchomeetModifyRecordsResp)(nil), // 32: EchomeetModifyRecordsResp - (*EchomeetDelRecordsReq)(nil), // 33: EchomeetDelRecordsReq - (*EchomeetDelRecordsResp)(nil), // 34: EchomeetDelRecordsResp - (*EchomeetAliCallbackReq)(nil), // 35: EchomeetAliCallbackReq - (*EchomeetAliCallbackResult)(nil), // 36: EchomeetAliCallbackResult - (*EchomeetAliSentence)(nil), // 37: EchomeetAliSentence - (*EchomeetAliCallbackResp)(nil), // 38: EchomeetAliCallbackResp - (*DBEchoMeetTemplate)(nil), // 39: DBEchoMeetTemplate - (*DBEchoMeetRecord)(nil), // 40: DBEchoMeetRecord - (*ContextStruct)(nil), // 41: ContextStruct - (TranscribeService)(0), // 42: TranscribeService - (*RecognitionResult)(nil), // 43: RecognitionResult - (*AudioInfo)(nil), // 44: AudioInfo + (*EchomeetGetTemplatesReq)(nil), // 0: EchomeetGetTemplatesReq + (*EchomeetGetTemplatesResp)(nil), // 1: EchomeetGetTemplatesResp + (*EchomeetAddTemplateReq)(nil), // 2: EchomeetAddTemplateReq + (*EchomeetAddTemplateResp)(nil), // 3: EchomeetAddTemplateResp + (*EchomeetUpdateTemplateReq)(nil), // 4: EchomeetUpdateTemplateReq + (*EchomeetUpdateTemplateResp)(nil), // 5: EchomeetUpdateTemplateResp + (*EchomeetDeleteTemplateReq)(nil), // 6: EchomeetDeleteTemplateReq + (*EchomeetDeleteTemplateResp)(nil), // 7: EchomeetDeleteTemplateResp + (*EchomeetGetAllRecordsReq)(nil), // 8: EchomeetGetAllRecordsReq + (*EchomeetGetAllRecordsResp)(nil), // 9: EchomeetGetAllRecordsResp + (*EchomeetGetRecordsReq)(nil), // 10: EchomeetGetRecordsReq + (*EchomeetGetRecordsResp)(nil), // 11: EchomeetGetRecordsResp + (*EchomeetGetRecordReq)(nil), // 12: EchomeetGetRecordReq + (*EchomeetGetRecordResp)(nil), // 13: EchomeetGetRecordResp + (*EchomeetPutRecordsReq)(nil), // 14: EchomeetPutRecordsReq + (*EchomeetPutRecordsResp)(nil), // 15: EchomeetPutRecordsResp + (*EchomeetAddRecordReq)(nil), // 16: EchomeetAddRecordReq + (*EchomeetAddRecordResp)(nil), // 17: EchomeetAddRecordResp + (*EchomeetUpRecordReq)(nil), // 18: EchomeetUpRecordReq + (*EchomeetUpRecordResp)(nil), // 19: EchomeetUpRecordResp + (*EchomeetStartTaskReq)(nil), // 20: EchomeetStartTaskReq + (*EchomeetStartTaskResp)(nil), // 21: EchomeetStartTaskResp + (*EchomeetCallbackReq)(nil), // 22: EchomeetCallbackReq + (*EchomeetCallbackResp)(nil), // 23: EchomeetCallbackResp + (*EchomeetSummaryReq)(nil), // 24: EchomeetSummaryReq + (*EchomeetSummaryResp)(nil), // 25: EchomeetSummaryResp + (*EchomeetReadRecordReq)(nil), // 26: EchomeetReadRecordReq + (*EchomeetReadRecordResp)(nil), // 27: EchomeetReadRecordResp + (*EchomeetModifyRecordsReq)(nil), // 28: EchomeetModifyRecordsReq + (*EchomeetModifyRecordsResp)(nil), // 29: EchomeetModifyRecordsResp + (*EchomeetDelRecordsReq)(nil), // 30: EchomeetDelRecordsReq + (*EchomeetDelRecordsResp)(nil), // 31: EchomeetDelRecordsResp + (*EchomeetAliCallbackReq)(nil), // 32: EchomeetAliCallbackReq + (*EchomeetAliCallbackResult)(nil), // 33: EchomeetAliCallbackResult + (*EchomeetAliSentence)(nil), // 34: EchomeetAliSentence + (*EchomeetAliCallbackResp)(nil), // 35: EchomeetAliCallbackResp + (*DBEchoMeetTemplate)(nil), // 36: DBEchoMeetTemplate + (*DBEchoMeetRecord)(nil), // 37: DBEchoMeetRecord + (*ContextStruct)(nil), // 38: ContextStruct + (TranscribeService)(0), // 39: TranscribeService + (*RecognitionResult)(nil), // 40: RecognitionResult + (*AudioInfo)(nil), // 41: AudioInfo } var file_echomeet_echomeet_msg_proto_depIdxs = []int32{ - 39, // 0: EchomeetGetTemplatesResp.templates:type_name -> DBEchoMeetTemplate - 39, // 1: EchomeetAddTemplateReq.template:type_name -> DBEchoMeetTemplate - 39, // 2: EchomeetAddTemplateResp.template:type_name -> DBEchoMeetTemplate - 39, // 3: EchomeetUpdateTemplateReq.template:type_name -> DBEchoMeetTemplate - 39, // 4: EchomeetUpdateTemplateResp.template:type_name -> DBEchoMeetTemplate - 40, // 5: EchomeetGetAllRecordsResp.records:type_name -> DBEchoMeetRecord - 40, // 6: EchomeetGetRecordsResp.records:type_name -> DBEchoMeetRecord - 40, // 7: EchomeetGetRecordResp.record:type_name -> DBEchoMeetRecord - 40, // 8: EchomeetPutRecordsReq.records:type_name -> DBEchoMeetRecord - 41, // 9: EchomeetAddRecordReq.original:type_name -> ContextStruct - 40, // 10: EchomeetAddRecordResp.record:type_name -> DBEchoMeetRecord - 40, // 11: EchomeetUpRecordResp.record:type_name -> DBEchoMeetRecord - 42, // 12: EchomeetStartTaskReq.service_type:type_name -> TranscribeService - 43, // 13: EchomeetCallbackReq.result:type_name -> RecognitionResult - 44, // 14: EchomeetCallbackReq.audio_info:type_name -> AudioInfo - 42, // 15: EchomeetSummaryReq.service_type:type_name -> TranscribeService - 26, // 16: EchomeetGetSummaryModelsResp.models:type_name -> SummaryModelInfo - 40, // 17: EchomeetModifyRecordsResp.record:type_name -> DBEchoMeetRecord - 36, // 18: EchomeetAliCallbackReq.Result:type_name -> EchomeetAliCallbackResult - 37, // 19: EchomeetAliCallbackResult.Sentences:type_name -> EchomeetAliSentence - 20, // [20:20] is the sub-list for method output_type - 20, // [20:20] is the sub-list for method input_type - 20, // [20:20] is the sub-list for extension type_name - 20, // [20:20] is the sub-list for extension extendee - 0, // [0:20] is the sub-list for field type_name + 36, // 0: EchomeetGetTemplatesResp.templates:type_name -> DBEchoMeetTemplate + 36, // 1: EchomeetAddTemplateReq.template:type_name -> DBEchoMeetTemplate + 36, // 2: EchomeetAddTemplateResp.template:type_name -> DBEchoMeetTemplate + 36, // 3: EchomeetUpdateTemplateReq.template:type_name -> DBEchoMeetTemplate + 36, // 4: EchomeetUpdateTemplateResp.template:type_name -> DBEchoMeetTemplate + 37, // 5: EchomeetGetAllRecordsResp.records:type_name -> DBEchoMeetRecord + 37, // 6: EchomeetGetRecordsResp.records:type_name -> DBEchoMeetRecord + 37, // 7: EchomeetGetRecordResp.record:type_name -> DBEchoMeetRecord + 37, // 8: EchomeetPutRecordsReq.records:type_name -> DBEchoMeetRecord + 38, // 9: EchomeetAddRecordReq.original:type_name -> ContextStruct + 37, // 10: EchomeetAddRecordResp.record:type_name -> DBEchoMeetRecord + 37, // 11: EchomeetUpRecordResp.record:type_name -> DBEchoMeetRecord + 39, // 12: EchomeetStartTaskReq.service_type:type_name -> TranscribeService + 40, // 13: EchomeetCallbackReq.result:type_name -> RecognitionResult + 41, // 14: EchomeetCallbackReq.audio_info:type_name -> AudioInfo + 39, // 15: EchomeetSummaryReq.service_type:type_name -> TranscribeService + 37, // 16: EchomeetModifyRecordsResp.record:type_name -> DBEchoMeetRecord + 33, // 17: EchomeetAliCallbackReq.Result:type_name -> EchomeetAliCallbackResult + 34, // 18: EchomeetAliCallbackResult.Sentences:type_name -> EchomeetAliSentence + 19, // [19:19] is the sub-list for method output_type + 19, // [19:19] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name } func init() { file_echomeet_echomeet_msg_proto_init() } @@ -2260,7 +2079,7 @@ func file_echomeet_echomeet_msg_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_echomeet_echomeet_msg_proto_rawDesc), len(file_echomeet_echomeet_msg_proto_rawDesc)), NumEnums: 0, - NumMessages: 39, + NumMessages: 36, NumExtensions: 0, NumServices: 0, }, diff --git a/apps/services/services/home/main.go b/apps/services/services/home/main.go index 3a178c6c..cbd8a17b 100644 --- a/apps/services/services/home/main.go +++ b/apps/services/services/home/main.go @@ -205,14 +205,17 @@ func (this *Service) InitSys() { log.Infof("init sys.email success!") } + // 支付子系统属「可选能力」:配置缺失/密钥错误时降级为不可用(与上面的 sys.translate 同策略), + // 不能 panic —— home 在 5-in-1 容器里一死,entrypoint 会连坐杀掉 gateway/api/timer/mcp, + // 一个错误的支付密钥就此变成全站 502。未初始化时包级函数返回 ErrNotInited,不会 panic。 if err := wechat.OnInit(this.GetSettings().Sys["wechatpay"]); err != nil { - panic(fmt.Sprintf("init sys.wechatpay err: %s", err.Error())) + log.Warnf("init sys.wechatpay err: %s (微信支付未配置,支付功能不可用)", err.Error()) } else { log.Infof("init sys.wechatpay success!") } if err := alipay.OnInit(this.GetSettings().Sys["alipay"]); err != nil { - panic(fmt.Sprintf("init sys.alipay err: %s", err.Error())) + log.Warnf("init sys.alipay err: %s (支付宝未配置,支付功能不可用)", err.Error()) } else { log.Infof("init sys.alipay success!") } diff --git a/apps/services/sys/aliyun/translate/throttle_test.go b/apps/services/sys/aliyun/translate/throttle_test.go new file mode 100644 index 00000000..9dc049df --- /dev/null +++ b/apps/services/sys/aliyun/translate/throttle_test.go @@ -0,0 +1,29 @@ +package translate + +import ( + "errors" + "testing" +) + +// 用线上真实的报错文本验证限流识别:识别不出来就不会重试,一次限流就把整篇译文废掉。 +func TestIsThrottling(t *testing.T) { + prodErr := errors.New(`translate request: SDK.ServerError +ErrorCode: Throttling.User +Recommend: https://api.aliyun.com/troubleshoot?q=Throttling.User&product=alimt +RequestId: 019F3BB4-F581-5AEE-80BE-74DB1FB673E1 +Message: Request was denied due to user flow control.`) + if !isThrottling(prodErr) { + t.Fatal("线上 Throttling.User 报错未被识别为限流,重试不会触发") + } + + if !isThrottling(errors.New("Request was denied due to user flow control.")) { + t.Error("flow control 文案应识别为限流") + } + // 非限流错误不应重试(例如参数错误),否则白白拖慢失败路径 + if isThrottling(errors.New("translate failed: code=10004 msg=empty source text")) { + t.Error("参数错误被误判为限流,会导致无谓重试") + } + if isThrottling(nil) { + t.Error("nil 不应判为限流") + } +} diff --git a/apps/services/sys/aliyun/translate/translate.go b/apps/services/sys/aliyun/translate/translate.go index 1b94dd11..fe5946c6 100644 --- a/apps/services/sys/aliyun/translate/translate.go +++ b/apps/services/sys/aliyun/translate/translate.go @@ -80,8 +80,50 @@ func (this *AliTranslate) translateOne(from, to, text string) (string, error) { return out.Data.Translated, nil } -// Translate 批量翻译,按 Concurrency 并发执行 TranslateGeneral +// isThrottling 判断是否为阿里 MT 的用户级流控错误(ErrorCode: Throttling.User)。 +// 这类错误是暂时的,退避后重试通常能成功。 +func isThrottling(err error) bool { + if err == nil { + return false + } + s := err.Error() + return strings.Contains(s, "Throttling") || strings.Contains(s, "flow control") +} + +// translateRetryBackoff 限流重试的退避间隔。阿里返回的 X-Acs-Retry-After 常达 80~170s, +// 全额等待会把一篇会议的翻译拖到十几分钟;这里用较短的递增退避,配合「失败降级为原文」兜底。 +var translateRetryBackoff = []time.Duration{500 * time.Millisecond, 2 * time.Second, 5 * time.Second} + +// translateOneWithRetry 单句翻译;遇到流控按 translateRetryBackoff 退避重试。 +// 非流控错误(如参数错误)不重试,直接返回。 +func (this *AliTranslate) translateOneWithRetry(ctx context.Context, from, to, text string) (string, error) { + var lastErr error + for attempt := 0; ; attempt++ { + r, e := this.translateOne(from, to, text) + if e == nil { + return r, nil + } + lastErr = e + if !isThrottling(e) || attempt >= len(translateRetryBackoff) { + return "", lastErr + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(translateRetryBackoff[attempt]): + } + } +} + +// Translate 批量翻译,按 Concurrency 并发执行 TranslateGeneral。 // from / to 为阿里 MT 语言码:zh / en / ja / ko / ...(保持调用方传入的标准) +// +// 容错语义(调用方需注意): +// - 单句重试后仍失败,**用原文占位**,results 始终与 texts 等长; +// - 部分失败:返回 (results, err) —— results 可用,err 描述失败句数,调用方记日志后应继续; +// - 全部失败:返回 (nil, err) —— 视为硬失败。 +// +// 这样一次限流不会把整篇会议的译文清空(进而让 AI 总结拿到空内容、报 content field is required)。 func (this *AliTranslate) Translate(ctx context.Context, from, to string, texts []string) (results []string, err error) { if len(texts) == 0 { return []string{}, nil @@ -107,16 +149,34 @@ func (this *AliTranslate) Translate(ctx context.Context, from, to string, texts results[idx] = text return } - r, e := this.translateOne(from, to, text) + r, e := this.translateOneWithRetry(ctx, from, to, text) + if e != nil { + results[idx] = text // 降级:保留原文,别让整批作废 + errs[idx] = e + return + } results[idx] = r - errs[idx] = e }(i, t) } wg.Wait() + + failed, firstIdx := 0, -1 + var firstErr error for i, e := range errs { if e != nil { - return nil, fmt.Errorf("translate index=%d failed: %w", i, e) + failed++ + if firstErr == nil { + firstErr, firstIdx = e, i + } } } - return + switch { + case failed == 0: + return results, nil + case failed == len(texts): + return nil, fmt.Errorf("translate all %d failed, first index=%d: %w", failed, firstIdx, firstErr) + default: + return results, fmt.Errorf("translate partial failure: %d/%d failed (已用原文占位), first index=%d: %w", + failed, len(texts), firstIdx, firstErr) + } } diff --git a/apps/services/sys/bytedance/translate/translate.go b/apps/services/sys/bytedance/translate/translate.go index e30ab8c5..2f561069 100644 --- a/apps/services/sys/bytedance/translate/translate.go +++ b/apps/services/sys/bytedance/translate/translate.go @@ -66,7 +66,9 @@ func (this *TranslateSys) Translate(ctx context.Context, from string, to string, TargetLanguage: volcengine.String(to), TextList: volcengine.StringSlice(batch), }); err != nil { - return + // 出错必须丢弃已翻好的部分:具名返回值裸 return 会带回一个长度不足的非 nil 切片, + // 调用方若据此判断「部分成功」,会把残缺译文当成完整结果用。 + return nil, err } for _, item := range resp.TranslationList { results = append(results, *item.Translation) diff --git a/apps/services/sys/pay/alipay/core.go b/apps/services/sys/pay/alipay/core.go index 5d3d19e7..fc3bcb56 100644 --- a/apps/services/sys/pay/alipay/core.go +++ b/apps/services/sys/pay/alipay/core.go @@ -2,6 +2,7 @@ package alipay import ( "context" + "errors" "net/url" ) @@ -26,24 +27,45 @@ type ( var defsys ISys -func OnInit(config map[string]interface{}, option ...Option) (err error) { - defsys, err = newSys(newOptions(config, option...)) - return +// ErrNotInited 支付宝未初始化。OnInit 失败时调用方可选择降级;此时包级函数返回本错误,而不是 panic。 +var ErrNotInited = errors.New("alipay 未初始化:配置缺失或密钥加载失败") + +func OnInit(config map[string]interface{}, option ...Option) error { + sys, err := newSys(newOptions(config, option...)) + if err != nil { + // 出错时不要给 defsys 赋值:newSys 出错时 sys 仍是非 nil 的 *Alipay(client 可能为 nil), + // 装进接口后 defsys != nil,守卫失效、调用时在 client 上 nil 解引用。 + return err + } + defsys = sys + return nil } -func NewSys(option ...Option) (sys ISys, err error) { - sys, err = newSys(newOptionsByOption(option...)) - return +func NewSys(option ...Option) (ISys, error) { + sys, err := newSys(newOptionsByOption(option...)) + if err != nil { + return nil, err + } + return sys, nil } func GenerateOrderNo(prefix string) string { + if defsys == nil { + return "" + } return defsys.GenerateOrderNo(prefix) } func CreateAppOrder(ctx context.Context, outTradeNo string, totalFee int64, description string, notifyurl string) (result string, err error) { + if defsys == nil { + return "", ErrNotInited + } return defsys.CreateAppOrder(ctx, outTradeNo, totalFee, description, notifyurl) } func DecryptWxPayNotify(values url.Values) (bool, error) { + if defsys == nil { + return false, ErrNotInited + } return defsys.DecryptWxPayNotify(values) } diff --git a/apps/services/sys/pay/alipay/notinited_test.go b/apps/services/sys/pay/alipay/notinited_test.go new file mode 100644 index 00000000..78088521 --- /dev/null +++ b/apps/services/sys/pay/alipay/notinited_test.go @@ -0,0 +1,33 @@ +package alipay + +import ( + "context" + "errors" + "net/url" + "testing" +) + +// 与 wechat 同型:alipay 的 newSys 出错时 sys 仍是非 nil 的 *Alipay(client 可能为 nil), +// 若直接 `defsys, err = newSys(...)` 会得到「非 nil 接口 + 半初始化对象」,守卫失效、调用时 nil 解引用。 +// OnInit 失败必须保持 defsys 为 nil,包级函数返回 ErrNotInited 而不是 panic。 +func TestOnInitFailure_GuardsHold(t *testing.T) { + defsys = nil + t.Cleanup(func() { defsys = nil }) + + if err := OnInit(map[string]interface{}{}); err == nil { + t.Fatal("空配置下 OnInit 应返回错误") + } + if defsys != nil { + t.Fatal("OnInit 失败后 defsys 必须保持 nil") + } + + if got := GenerateOrderNo("Alipay"); got != "" { + t.Errorf("未初始化时 GenerateOrderNo 应返回空串,实际=%q", got) + } + if _, e := CreateAppOrder(context.Background(), "no1", 1, "d", "u"); !errors.Is(e, ErrNotInited) { + t.Errorf("未初始化时 CreateAppOrder 应返回 ErrNotInited,实际=%v", e) + } + if ok, e := DecryptWxPayNotify(url.Values{}); ok || !errors.Is(e, ErrNotInited) { + t.Errorf("未初始化时 DecryptWxPayNotify 应返回 (false, ErrNotInited),实际=(%v,%v)", ok, e) + } +} diff --git a/apps/services/sys/pay/wechat/core.go b/apps/services/sys/pay/wechat/core.go index dcee9f43..8d1958d5 100644 --- a/apps/services/sys/pay/wechat/core.go +++ b/apps/services/sys/pay/wechat/core.go @@ -1,6 +1,9 @@ package wechat -import "context" +import ( + "context" + "errors" +) type ( // 预支付响应参数(返回给Flutter客户端) @@ -58,24 +61,47 @@ type ( var defsys ISys -func OnInit(config map[string]interface{}, option ...Option) (err error) { - defsys, err = newSys(newOptions(config, option...)) - return +// ErrNotInited 微信支付未初始化。OnInit 失败时不再由库 Fatal,调用方可选择降级; +// 此时包级函数返回本错误,而不是 panic。 +var ErrNotInited = errors.New("wechatpay 未初始化:配置缺失或商户私钥加载失败") + +func OnInit(config map[string]interface{}, option ...Option) error { + sys, err := newSys(newOptions(config, option...)) + if err != nil { + // 关键:出错时**不要**给 defsys 赋值。 + // `defsys, err = newSys(...)` 会把 (*WeChat)(nil) 装进 ISys 接口,得到「非 nil 接口 + nil 指针」, + // 于是 defsys == nil 判断失效、后续调用直接 panic。保持 defsys 为 nil 才能被守卫拦住。 + return err + } + defsys = sys + return nil } -func NewSys(option ...Option) (sys ISys, err error) { - sys, err = newSys(newOptionsByOption(option...)) - return +func NewSys(option ...Option) (ISys, error) { + sys, err := newSys(newOptionsByOption(option...)) + if err != nil { + return nil, err // 同上:不返回装着 nil 指针的非 nil 接口 + } + return sys, nil } func GenerateOrderNo(prefix string) string { + if defsys == nil { + return "" + } return defsys.GenerateOrderNo(prefix) } func CreateAppOrder(ctx context.Context, outTradeNo string, totalFee int64, description string, notifyurl string) (result *AppPayParams, err error) { + if defsys == nil { + return nil, ErrNotInited + } return defsys.CreateAppOrder(ctx, outTradeNo, totalFee, description, notifyurl) } func DecryptWxPayNotify(associatedData, nonce, ciphertext string) (*WxPayTransaction, error) { + if defsys == nil { + return nil, ErrNotInited + } return defsys.DecryptWxPayNotify(associatedData, nonce, ciphertext) } diff --git a/apps/services/sys/pay/wechat/notinited_test.go b/apps/services/sys/pay/wechat/notinited_test.go new file mode 100644 index 00000000..4d1c0fc8 --- /dev/null +++ b/apps/services/sys/pay/wechat/notinited_test.go @@ -0,0 +1,36 @@ +package wechat + +import ( + "context" + "errors" + "testing" +) + +// 事故回归:wechat 初始化失败时(如商户私钥加载失败), +// 1. 库不得 Fatal/panic(原来 newSys 里是 log.Fatalf,直接杀掉 home 进程 → 容器连坐 → 全站 502); +// 2. defsys 必须保持 nil(不能把 (*WeChat)(nil) 装进接口,否则守卫失效、调用即 panic); +// 3. 包级函数返回 ErrNotInited 而不是 panic。 +func TestOnInitFailure_NoFatal_GuardsHold(t *testing.T) { + defsys = nil // 与同包其它测试隔离 + t.Cleanup(func() { defsys = nil }) + + // 空配置 → 私钥路径为空 → 加载失败 + err := OnInit(map[string]interface{}{}) + if err == nil { + t.Fatal("空配置下 OnInit 应返回错误") + } + if defsys != nil { + t.Fatal("OnInit 失败后 defsys 必须保持 nil,否则是「非 nil 接口 + nil 指针」,调用即 panic") + } + + // 以下调用都不能 panic + if got := GenerateOrderNo("WxAPP"); got != "" { + t.Errorf("未初始化时 GenerateOrderNo 应返回空串,实际=%q", got) + } + if _, e := CreateAppOrder(context.Background(), "no1", 1, "d", "u"); !errors.Is(e, ErrNotInited) { + t.Errorf("未初始化时 CreateAppOrder 应返回 ErrNotInited,实际=%v", e) + } + if _, e := DecryptWxPayNotify("a", "n", "c"); !errors.Is(e, ErrNotInited) { + t.Errorf("未初始化时 DecryptWxPayNotify 应返回 ErrNotInited,实际=%v", e) + } +} diff --git a/apps/services/sys/pay/wechat/wechat.go b/apps/services/sys/pay/wechat/wechat.go index 59de635a..6a289237 100644 --- a/apps/services/sys/pay/wechat/wechat.go +++ b/apps/services/sys/pay/wechat/wechat.go @@ -5,7 +5,6 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rsa" - "yunyan/lego/sys/log" "encoding/base64" "encoding/json" "fmt" @@ -32,7 +31,10 @@ func newSys(options Options) (sys *WeChat, err error) { privateKey, err = utils.LoadPrivateKeyWithPath(options.PrivateKeyPath) } if err != nil { - log.Fatalf("加载商户私钥失败: %v", err) + // 库里绝不能 Fatal/panic:这里曾经是 log.Fatalf,一个错误的商户私钥配置就能直接杀掉 home 进程, + // 而 home 在 5-in-1 容器里一死会连坐 gateway/api/timer/mcp,表现为全站 502。 + // 由调用方决定是硬失败还是降级。 + return nil, fmt.Errorf("加载商户私钥失败: %w", err) } // 2. 初始化客户端选项(推荐自动证书更新模式) @@ -48,11 +50,10 @@ func newSys(options Options) (sys *WeChat, err error) { } // 3. 创建微信支付客户端 - sys.client, err = core.NewClient(ctx, opts...) - if err != nil { - log.Errorf("创建客户端失败: %v", err) + if sys.client, err = core.NewClient(ctx, opts...); err != nil { + return nil, fmt.Errorf("创建微信支付客户端失败: %w", err) } - return + return sys, nil } type WeChat struct {