Browse Source

上传代码修复

main
liwei1dao 3 months ago
parent
commit
99de0fa26f
  1. 1
      apps/proto/echomeet/echomeet_db.proto
  2. 19
      apps/proto/echomeet/echomeet_msg.proto
  3. 1
      apps/services/comm/log
  4. 35
      apps/services/comm/moduleconfig.go
  5. 64
      apps/services/comm/moduleconfig_failclosed_test.go
  6. 22
      apps/services/modules/echomeet/CLAUDE.md
  7. 30
      apps/services/modules/echomeet/api_getsummarymodels.go
  8. 17
      apps/services/modules/echomeet/api_starttask.go
  9. 8
      apps/services/modules/echomeet/api_summary.go
  10. 89
      apps/services/modules/echomeet/fix_verify_test.go
  11. 20
      apps/services/modules/echomeet/provider_ali.go
  12. 56
      apps/services/modules/echomeet/providers.go
  13. 466
      apps/services/modules/echomeet/svcconfig.go
  14. 99
      apps/services/modules/echomeet/tasks.go
  15. 13
      apps/services/pb/echomeet_db.pb.go
  16. 337
      apps/services/pb/echomeet_msg.pb.go
  17. 7
      apps/services/services/home/main.go
  18. 29
      apps/services/sys/aliyun/translate/throttle_test.go
  19. 70
      apps/services/sys/aliyun/translate/translate.go
  20. 4
      apps/services/sys/bytedance/translate/translate.go
  21. 34
      apps/services/sys/pay/alipay/core.go
  22. 33
      apps/services/sys/pay/alipay/notinited_test.go
  23. 40
      apps/services/sys/pay/wechat/core.go
  24. 36
      apps/services/sys/pay/wechat/notinited_test.go
  25. 13
      apps/services/sys/pay/wechat/wechat.go

1
apps/proto/echomeet/echomeet_db.proto

@ -72,7 +72,6 @@ message DBEchoMeetRecord {
int64 lastquerytime = 27; //@go_tags(`gorm:"lastquerytime" json:"lastquerytime"`) 上一次主动查询第三方服务的 unix 秒,用于限制查询频率 int64 lastquerytime = 27; //@go_tags(`gorm:"lastquerytime" json:"lastquerytime"`) 上一次主动查询第三方服务的 unix 秒,用于限制查询频率
string remark = 28; //@go_tags(`gorm:"remark" json:"remark"`) 用户备注(创建记录时填写,参与AI总结) string remark = 28; //@go_tags(`gorm:"remark" json:"remark"`) 用户备注(创建记录时填写,参与AI总结)
string imageurls = 29; //@go_tags(`gorm:"imageurls" json:"imageurls"`) 注释图片url列表(JSON数组字符串,编码方式同original),作为多模态内容参与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{ message ContextStruct{

19
apps/proto/echomeet/echomeet_msg.proto

@ -112,8 +112,7 @@ message EchomeetStartTaskReq{
bool isdistinguishspeaker = 4; //@go_tags(`json:"isdistinguishspeaker"`) //是否区分说话人 bool isdistinguishspeaker = 4; //@go_tags(`json:"isdistinguishspeaker"`) //是否区分说话人
uint64 templateid = 5; //@go_tags(`json:"templateid"`) //会议记录模版索引id uint64 templateid = 5; //@go_tags(`json:"templateid"`) //会议记录模版索引id
string tid = 6; //@go_tags(`json:"tid"`) //会议记录模板tid string tid = 6; //@go_tags(`json:"tid"`) //会议记录模板tid
TranscribeService service_type = 7; //@go_tags(`json:"service_type"`) //转写服务类型,留空默认 Auto(识别现按后台优先级+语言自动选择) TranscribeService service_type = 7; //@go_tags(`json:"service_type"`) //转写服务类型,留空默认 Auto
string summary_model = 8; //@go_tags(`json:"summary_model"`) //总结所用文本大模型条目id(echomeet_getsummarymodels 返回),留空=按优先级默认
} }
message EchomeetStartTaskResp{ message EchomeetStartTaskResp{
@ -142,28 +141,12 @@ message EchomeetSummaryReq {
string tid = 6; //@go_tags(`json:"tid"`) //会议记录模板tid string tid = 6; //@go_tags(`json:"tid"`) //会议记录模板tid
string tolanguage = 7; //@go_tags(`json:"tolanguage"`) 语言 string tolanguage = 7; //@go_tags(`json:"tolanguage"`) 语言
TranscribeService service_type = 8; //@go_tags(`json:"service_type"`) //转写服务类型(用于翻译/总结路由),留空沿用记录已有 TranscribeService service_type = 8; //@go_tags(`json:"service_type"`) //转写服务类型(用于翻译/总结路由),留空沿用记录已有
string summary_model = 9; //@go_tags(`json:"summary_model"`) //总结所用文本大模型条目id(echomeet_getsummarymodels 返回),留空沿用记录已有
} }
message EchomeetSummaryResp{ 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"`) //可选总结模型清单(按优先级)
}
//已阅会议记录 //已阅会议记录
message EchomeetReadRecordReq{ message EchomeetReadRecordReq{
uint64 id = 1; //@go_tags(`json:"id"`) //会议记录id uint64 id = 1; //@go_tags(`json:"id"`) //会议记录id

1
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 完全一致

35
apps/services/comm/moduleconfig.go

@ -186,6 +186,26 @@ func ModuleFieldIsSecret(module, key string) bool {
// //
// 按模块粒度处理:某模块在库里有行 → 覆盖 Sys(改配置需重启才生效);库里无该模块 → 把当前 yaml 值 seed 进库 // 按模块粒度处理:某模块在库里有行 → 覆盖 Sys(改配置需重启才生效);库里无该模块 → 把当前 yaml 值 seed 进库
// (source 语义由存在与否表达;密钥字段加密落库),Sys 保持 yaml 原值不动。best-effort: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 { func LoadOrSeedModuleConfig(db mysql.ISys, encKey string, sys map[string]map[string]interface{}) error {
if db == nil { if db == nil {
return nil return nil
@ -211,20 +231,7 @@ func LoadOrSeedModuleConfig(db mysql.ISys, encKey string, sys map[string]map[str
sec = map[string]interface{}{} sec = map[string]interface{}{}
sys[def.SysKey] = sec sys[def.SysKey] = sec
} }
for _, r := range existing { applyDBValues(sec, existing, encKey)
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])
}
continue continue
} }
// 库无该模块:把当前 yaml 值 seed 进库(仅 seed yaml 里确有的字段;yaml 无此段则跳过)。 // 库无该模块:把当前 yaml 值 seed 进库(仅 seed yaml 里确有的字段;yaml 无此段则跳过)。

64
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"])
}
}

22
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`。 `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`): 四套服务商,每套三件套(见对应 `provider_*.go`):
| ServiceType | 转写 | 翻译 | 总结(Summarizer) | | ServiceType | 转写 | 翻译 | 总结(Summarizer) |
|---|---|---|---| |---|---|---|---|
| ByteDance | `sys/bytedance/audomodel` | 火山翻译 | 豆包 `sys/doubao` | | 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` | | Google | Cloud Speech | Cloud Translation | Gemini `sys/google/gemini` |
| Microsoft | Azure Speech | Azure Translator | Azure OpenAI(`sys/openai` 兼容端点)| | Microsoft | Azure Speech | Azure Translator | Azure OpenAI(`sys/openai` 兼容端点)|
三类能力抽象为 `Transcriber` / `Translator` / `Summarizer` 接口(providers.go)。**新的选择/配置机制在 [svcconfig.go](svcconfig.go)**: 三类能力抽象为 `Transcriber` / `Translator` / `Summarizer` 接口(providers.go),运行期 `GetTranscriber/GetTranslator/GetSummarizer(svcType, language)` 按 ServiceType 路由。新增服务商 = 写一个 `provider_xxx.go` 实现三接口 + 在 `providersComp.Init` 注册进三张 map。
- 配置表 `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 映射。
## 组件与异步流水线 ## 组件与异步流水线

30
apps/services/modules/echomeet/api_getsummarymodels.go

@ -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
}

17
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.Isdistinguishspeaker = req.Isdistinguishspeaker
model.Formlanguage = req.Formlanguage model.Formlanguage = req.Formlanguage
model.Tolanguage = req.Tolanguage 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.ResolveTranscribeService(req.ServiceType, model.Formlanguage)
model.ServiceType = this.module.providers.selectASR(model.Formlanguage)
}
// 总结所用文本大模型:客户端选择优先,留空则用后台配置的默认(优先级最高的启用条目)。
model.SummaryModel = req.SummaryModel
if model.SummaryModel == "" {
model.SummaryModel = this.module.providers.DefaultSummaryId()
}
this.module.model.saverecord(model) 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", this.module.Infof("StartTask id:%d uid:%s formlang:%s tolang:%s seconds:%d size:%d distinguishSpeaker:%v service:%s",

8
apps/services/modules/echomeet/api_summary.go

@ -62,13 +62,7 @@ func (this *apiComp) Summary(session comm.IUserSession, req *pb.EchomeetSummaryR
svc = model.ServiceType svc = model.ServiceType
} }
model.ServiceType = this.module.providers.ResolveServiceType(svc, model.Formlanguage) model.ServiceType = this.module.providers.ResolveServiceType(svc, model.Formlanguage)
// 总结所用文本大模型:客户端显式指定则更新,否则沿用记录已有(为空再回退默认)。 this.module.Infof("Summary id:%d → service:%s", model.Id, ServiceTypeName(model.ServiceType))
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)
// 是否需要重新翻译: // 是否需要重新翻译:
// - 客户端显式传入 tolanguage(重新总结操作,强制重译以保证 Translate 与 Tolanguage 一致);或 // - 客户端显式传入 tolanguage(重新总结操作,强制重译以保证 Translate 与 Tolanguage 一致);或
// - 目标语言变化;或 // - 目标语言变化;或

89
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,让调用方告警")
}
}

20
apps/services/modules/echomeet/provider_ali.go

@ -8,6 +8,7 @@ import (
"yunyan/sys/aliyun/translate" "yunyan/sys/aliyun/translate"
"yunyan/sys/openai" "yunyan/sys/openai"
"fmt" "fmt"
"strings"
) )
// newAliProviders 初始化阿里系单元服务(转写=DashScope 录音文件转写、翻译=阿里翻译、 // newAliProviders 初始化阿里系单元服务(转写=DashScope 录音文件转写、翻译=阿里翻译、
@ -38,9 +39,22 @@ func (this *aliTranscriber) Name() pb.TranscribeService {
return pb.TranscribeService_TranscribeService_Ali return pb.TranscribeService_TranscribeService_Ali
} }
// 阿里 DashScope 对常见语种有 hints 支持;不支持时也允许由 DashScope 自动检测,故默认返回 true // dashScopeParaformerV2Langs 是 filetrans 所用模型 paraformer-v2 **实际**支持的语种(DashScope 语言码)。
func (this *aliTranscriber) SupportsLanguage(_ string) bool { //
return true // 坑: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) { func (this *aliTranscriber) Submit(_ context.Context, req SubmitRequest) (*SubmitResult, error) {

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

@ -2,7 +2,6 @@ package echomeet
import ( import (
"context" "context"
"yunyan/comm"
"yunyan/lego/core" "yunyan/lego/core"
"yunyan/lego/core/cbase" "yunyan/lego/core/cbase"
"yunyan/pb" "yunyan/pb"
@ -21,14 +20,6 @@ type providersComp struct {
transcribers map[pb.TranscribeService]Transcriber transcribers map[pb.TranscribeService]Transcriber
translators map[pb.TranscribeService]Translator translators map[pb.TranscribeService]Translator
summarizers map[pb.TranscribeService]Summarizer 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 包 // 任务状态常量,对齐底层 sys 包
@ -126,10 +117,6 @@ func (this *providersComp) Init(service core.IService, module core.IModule, comp
pb.TranscribeService_TranscribeService_Microsoft: msSummarizer, pb.TranscribeService_TranscribeService_Microsoft: msSummarizer,
} }
this.module.Infof("echomeet providers 初始化完成 DefaultService=%s", opt.DefaultService) this.module.Infof("echomeet providers 初始化完成 DefaultService=%s", opt.DefaultService)
// 会议记录服务配置:从 yaml seed 进应用业务库(幂等),并加载识别/翻译的优先级清单。
// best-effort:失败不阻断启动,选择逻辑会回退到旧的 ResolveServiceType。
this.loadOrSeedSvcConfig(opt)
return return
} }
@ -170,6 +157,49 @@ func (this *providersComp) ResolveServiceType(svcType pb.TranscribeService, lang
return pb.TranscribeService_TranscribeService_Ali 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。 // GetTranscriber 取转写 provider;svcType=Auto/Unknown 时先 ResolveServiceType。

466
apps/services/modules/echomeet/svcconfig.go

@ -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
}

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

@ -32,6 +32,10 @@ type tasksComp struct {
service comm.IService service comm.IService
artshortPool *ants.PoolWithFunc //小音频转写池塘 artshortPool *ants.PoolWithFunc //小音频转写池塘
aipool *ants.PoolWithFunc //AI总结池塘 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) { 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 { for _, item := range contexts {
tests = append(tests, item.Content) tests = append(tests, item.Content)
} }
// 翻译服务按「后台配置的翻译优先级」独立选择;无配置时回退到识别所用服务商(旧的三段同源行为)。 translator, err := this.module.providers.GetTranslator(rec.ServiceType, rec.Formlanguage)
mtSvc := this.module.providers.selectMT(rec.Formlanguage, rec.Tolanguage, rec.ServiceType)
translator, err := this.module.providers.GetTranslator(mtSvc, rec.Formlanguage)
if err != nil { if err != nil {
this.module.Errorf("TranslateProcess id:%d 取 translator 失败 err:%v", rec.Id, err) this.module.Errorf("TranslateProcess id:%d 取 translator 失败 err:%v", rec.Id, err)
return err return err
@ -360,10 +362,18 @@ func (this *tasksComp) TranslateProcess(rec *pb.DBEchoMeetRecord, contexts []*pb
// 这里直接传 BCP-47,由各 provider 自行转换为其平台的语言码。 // 这里直接传 BCP-47,由各 provider 自行转换为其平台的语言码。
// 不要在这里调用 toTranslateLang —— 否则 provider 内部再调一次会把 "en" 当未知 fallback 成 "zh",最终发出去的是 zh→zh。 // 不要在这里调用 toTranslateLang —— 否则 provider 内部再调一次会把 "en" 当未知 fallback 成 "zh",最终发出去的是 zh→zh。
results, err = translator.Translate(context.Background(), rec.Formlanguage, rec.Tolanguage, tests) results, err = translator.Translate(context.Background(), rec.Formlanguage, rec.Tolanguage, tests)
if err != nil { // 判定「结果是否可用」只认长度对齐,不能只看 results==nil:
this.module.Errorf("TranslateProcess id:%d 翻译失败 service:%s err:%v", rec.Id, ServiceTypeName(translator.Name()), err) // 有的 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 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 { for i, item := range contexts {
if i >= len(results) { if i >= len(results) {
this.module.Warnf("TranslateProcess id:%d 翻译结果数量不足 期望:%d 实际:%d", rec.Id, len(contexts), 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 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) 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 { for _, v := range contexts {
originalText += fmt.Sprintf("[%s]:%s\n", v.Speaker, v.Content) originalText += fmt.Sprintf("[%s]:%s\n", v.Speaker, v.Content)
} }
} else { } 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 总结。 // 用户在创建记录时填写的备注,与转写内容一并提交给 AI 总结。
// 用语言中立的结构化标签包裹,避免硬编码某种语言的提示词,污染多语言场景下的总结输出。 // 用语言中立的结构化标签包裹,避免硬编码某种语言的提示词,污染多语言场景下的总结输出。
@ -427,19 +456,15 @@ func (this *tasksComp) AIProcess(task interface{}) {
_ = redissys.Conn().LRem(context.Background(), procKey, 1, fmt.Sprintf("%d", rec.Id)).Err() _ = redissys.Conn().LRem(context.Background(), procKey, 1, fmt.Sprintf("%d", rec.Id)).Err()
return return
} }
// 总结按客户端所选文本大模型条目 id 取实例;空/未命中回退优先级最高的启用条目,再回退旧的按服务商路由。 summarizer, err := this.module.providers.GetSummarizer(rec.ServiceType, rec.Formlanguage)
summarizer := this.module.providers.GetSummarizerById(rec.SummaryModel) if err != nil {
if summarizer == nil { this.module.Errorf("AIProcess id:%d 取 summarizer 失败 err:%v", rec.Id, err)
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 rec.State = pb.DBEchoMeetRecordState_SummarizFail
this.module.model.saverecord(rec) this.module.model.saverecord(rec)
procKey := this.keyAIProc() procKey := this.keyAIProc()
_ = redissys.Conn().LRem(context.Background(), procKey, 1, fmt.Sprintf("%d", rec.Id)).Err() _ = redissys.Conn().LRem(context.Background(), procKey, 1, fmt.Sprintf("%d", rec.Id)).Err()
return return
} }
}
this.module.Infof("AIProcess id:%d → service:%s", rec.Id, ServiceTypeName(summarizer.Name())) this.module.Infof("AIProcess id:%d → service:%s", rec.Id, ServiceTypeName(summarizer.Name()))
// 注释图片:用户创建记录时填写的 imageurls(JSON 数组字符串),解析后作为多模态内容随 // 注释图片:用户创建记录时填写的 imageurls(JSON 数组字符串),解析后作为多模态内容随
// user 消息发给模型,让总结结合图片(白板/PPT/截图等)。为空/解析失败时 Images 为 nil, // user 消息发给模型,让总结结合图片(白板/PPT/截图等)。为空/解析失败时 Images 为 nil,
@ -513,6 +538,11 @@ func (this *tasksComp) PollTranscribe(rec *pb.DBEchoMeetRecord) {
} }
switch result.Status { switch result.Status {
case StatusSuccess: case StatusSuccess:
// 重入保护:本条已在收尾(翻译)中则直接返回,避免并发轮询重复翻译整篇。
if _, busy := this.finishing.LoadOrStore(rec.Id, struct{}{}); busy {
this.module.Debugf("转写轮询 id:%d 收尾进行中,跳过", rec.Id)
return
}
personnels := make(map[string]struct{}) personnels := make(map[string]struct{})
for _, c := range result.Contexts { for _, c := range result.Contexts {
c.Meetingid = rec.Id c.Meetingid = rec.Id
@ -526,10 +556,13 @@ func (this *tasksComp) PollTranscribe(rec *pb.DBEchoMeetRecord) {
rec.Personnel += fmt.Sprintf("[%s] ", k) rec.Personnel += fmt.Sprintf("[%s] ", k)
} }
rec.Original = utils.ToString(result.Contexts) 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.model.saverecord(rec)
this.module.Infof("转写轮询 id:%d 转写完成,提交AI任务", rec.Id) // 翻译从 HTTP 轮询路径移到后台:原来在这里同步翻几百句(~20s),把 getrecord 拖成 20s 响应,
this.SubmitAITask(rec) // 且状态直到翻完才翻转,导致 10s 一次的轮询不断重入、并发重译 → 触发翻译服务限流。
// 只传 id:调用方(getrecord)返回后仍会序列化它持有的那个 rec 指针,把同一指针交给后台协程会产生数据竞争。
go this.finishTranscribe(rec.Id, result.Contexts)
case StatusRunning: case StatusRunning:
this.module.Debugf("转写轮询 id:%d 仍在处理中", rec.Id) this.module.Debugf("转写轮询 id:%d 仍在处理中", rec.Id)
default: default:
@ -538,3 +571,35 @@ func (this *tasksComp) PollTranscribe(rec *pb.DBEchoMeetRecord) {
this.module.model.saverecord(rec) 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)
}

13
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 秒,用于限制查询频率 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总结) 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总结 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -550,13 +549,6 @@ func (x *DBEchoMeetRecord) GetImageurls() string {
return "" return ""
} }
func (x *DBEchoMeetRecord) GetSummaryModel() string {
if x != nil {
return x.SummaryModel
}
return ""
}
type ContextStruct struct { type ContextStruct struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Meetingid uint64 `protobuf:"varint,1,opt,name=Meetingid,proto3" json:"meetingid"` //@go_tags(`json:"meetingid"`) 会议id 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" + "\aoutline\x18\n" +
" \x01(\tR\aoutline\x12\x1a\n" + " \x01(\tR\aoutline\x12\x1a\n" +
"\btemplate\x18\v \x01(\tR\btemplate\x12\x12\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" + "\x10DBEchoMeetRecord\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x10\n" + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x10\n" +
"\x03uid\x18\x02 \x01(\tR\x03uid\x12,\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" + "\x0fprocessduration\x18\x1a \x01(\x03R\x0fprocessduration\x12$\n" +
"\rlastquerytime\x18\x1b \x01(\x03R\rlastquerytime\x12\x16\n" + "\rlastquerytime\x18\x1b \x01(\x03R\rlastquerytime\x12\x16\n" +
"\x06remark\x18\x1c \x01(\tR\x06remark\x12\x1c\n" + "\x06remark\x18\x1c \x01(\tR\x06remark\x12\x1c\n" +
"\timageurls\x18\x1d \x01(\tR\timageurls\x12#\n" + "\timageurls\x18\x1d \x01(\tR\timageurls\"\x99\x01\n" +
"\rsummary_model\x18\x1e \x01(\tR\fsummaryModel\"\x99\x01\n" +
"\rContextStruct\x12\x1c\n" + "\rContextStruct\x12\x1c\n" +
"\tMeetingid\x18\x01 \x01(\x04R\tMeetingid\x12\x18\n" + "\tMeetingid\x18\x01 \x01(\x04R\tMeetingid\x12\x18\n" +
"\aContent\x18\x02 \x01(\tR\aContent\x12\x1c\n" + "\aContent\x18\x02 \x01(\tR\aContent\x12\x1c\n" +

337
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"`) //是否区分说话人 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 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 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(识别现按后台优先级+语言自动选择) 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 返回),留空=按优先级默认
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -1060,13 +1059,6 @@ func (x *EchomeetStartTaskReq) GetServiceType() TranscribeService {
return TranscribeService_TranscribeService_Unknown return TranscribeService_TranscribeService_Unknown
} }
func (x *EchomeetStartTaskReq) GetSummaryModel() string {
if x != nil {
return x.SummaryModel
}
return ""
}
type EchomeetStartTaskResp struct { type EchomeetStartTaskResp struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields 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 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"`) 语言 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"`) //转写服务类型(用于翻译/总结路由),留空沿用记录已有 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -1302,13 +1293,6 @@ func (x *EchomeetSummaryReq) GetServiceType() TranscribeService {
return TranscribeService_TranscribeService_Unknown return TranscribeService_TranscribeService_Unknown
} }
func (x *EchomeetSummaryReq) GetSummaryModel() string {
if x != nil {
return x.SummaryModel
}
return ""
}
type EchomeetSummaryResp struct { type EchomeetSummaryResp struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
@ -1345,156 +1329,6 @@ func (*EchomeetSummaryResp) Descriptor() ([]byte, []int) {
return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{25} 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 { type EchomeetReadRecordReq struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -1505,7 +1339,7 @@ type EchomeetReadRecordReq struct {
func (x *EchomeetReadRecordReq) Reset() { func (x *EchomeetReadRecordReq) Reset() {
*x = EchomeetReadRecordReq{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1517,7 +1351,7 @@ func (x *EchomeetReadRecordReq) String() string {
func (*EchomeetReadRecordReq) ProtoMessage() {} func (*EchomeetReadRecordReq) ProtoMessage() {}
func (x *EchomeetReadRecordReq) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1530,7 +1364,7 @@ func (x *EchomeetReadRecordReq) ProtoReflect() protoreflect.Message {
// Deprecated: Use EchomeetReadRecordReq.ProtoReflect.Descriptor instead. // Deprecated: Use EchomeetReadRecordReq.ProtoReflect.Descriptor instead.
func (*EchomeetReadRecordReq) Descriptor() ([]byte, []int) { 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 { func (x *EchomeetReadRecordReq) GetId() uint64 {
@ -1548,7 +1382,7 @@ type EchomeetReadRecordResp struct {
func (x *EchomeetReadRecordResp) Reset() { func (x *EchomeetReadRecordResp) Reset() {
*x = EchomeetReadRecordResp{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1560,7 +1394,7 @@ func (x *EchomeetReadRecordResp) String() string {
func (*EchomeetReadRecordResp) ProtoMessage() {} func (*EchomeetReadRecordResp) ProtoMessage() {}
func (x *EchomeetReadRecordResp) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1573,7 +1407,7 @@ func (x *EchomeetReadRecordResp) ProtoReflect() protoreflect.Message {
// Deprecated: Use EchomeetReadRecordResp.ProtoReflect.Descriptor instead. // Deprecated: Use EchomeetReadRecordResp.ProtoReflect.Descriptor instead.
func (*EchomeetReadRecordResp) Descriptor() ([]byte, []int) { 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() { func (x *EchomeetModifyRecordsReq) Reset() {
*x = EchomeetModifyRecordsReq{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1603,7 +1437,7 @@ func (x *EchomeetModifyRecordsReq) String() string {
func (*EchomeetModifyRecordsReq) ProtoMessage() {} func (*EchomeetModifyRecordsReq) ProtoMessage() {}
func (x *EchomeetModifyRecordsReq) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1616,7 +1450,7 @@ func (x *EchomeetModifyRecordsReq) ProtoReflect() protoreflect.Message {
// Deprecated: Use EchomeetModifyRecordsReq.ProtoReflect.Descriptor instead. // Deprecated: Use EchomeetModifyRecordsReq.ProtoReflect.Descriptor instead.
func (*EchomeetModifyRecordsReq) Descriptor() ([]byte, []int) { 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 { func (x *EchomeetModifyRecordsReq) GetId() uint64 {
@ -1670,7 +1504,7 @@ type EchomeetModifyRecordsResp struct {
func (x *EchomeetModifyRecordsResp) Reset() { func (x *EchomeetModifyRecordsResp) Reset() {
*x = EchomeetModifyRecordsResp{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1682,7 +1516,7 @@ func (x *EchomeetModifyRecordsResp) String() string {
func (*EchomeetModifyRecordsResp) ProtoMessage() {} func (*EchomeetModifyRecordsResp) ProtoMessage() {}
func (x *EchomeetModifyRecordsResp) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1695,7 +1529,7 @@ func (x *EchomeetModifyRecordsResp) ProtoReflect() protoreflect.Message {
// Deprecated: Use EchomeetModifyRecordsResp.ProtoReflect.Descriptor instead. // Deprecated: Use EchomeetModifyRecordsResp.ProtoReflect.Descriptor instead.
func (*EchomeetModifyRecordsResp) Descriptor() ([]byte, []int) { 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 { func (x *EchomeetModifyRecordsResp) GetRecord() *DBEchoMeetRecord {
@ -1715,7 +1549,7 @@ type EchomeetDelRecordsReq struct {
func (x *EchomeetDelRecordsReq) Reset() { func (x *EchomeetDelRecordsReq) Reset() {
*x = EchomeetDelRecordsReq{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1727,7 +1561,7 @@ func (x *EchomeetDelRecordsReq) String() string {
func (*EchomeetDelRecordsReq) ProtoMessage() {} func (*EchomeetDelRecordsReq) ProtoMessage() {}
func (x *EchomeetDelRecordsReq) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1740,7 +1574,7 @@ func (x *EchomeetDelRecordsReq) ProtoReflect() protoreflect.Message {
// Deprecated: Use EchomeetDelRecordsReq.ProtoReflect.Descriptor instead. // Deprecated: Use EchomeetDelRecordsReq.ProtoReflect.Descriptor instead.
func (*EchomeetDelRecordsReq) Descriptor() ([]byte, []int) { 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 { func (x *EchomeetDelRecordsReq) GetIds() []uint64 {
@ -1759,7 +1593,7 @@ type EchomeetDelRecordsResp struct {
func (x *EchomeetDelRecordsResp) Reset() { func (x *EchomeetDelRecordsResp) Reset() {
*x = EchomeetDelRecordsResp{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1771,7 +1605,7 @@ func (x *EchomeetDelRecordsResp) String() string {
func (*EchomeetDelRecordsResp) ProtoMessage() {} func (*EchomeetDelRecordsResp) ProtoMessage() {}
func (x *EchomeetDelRecordsResp) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1784,7 +1618,7 @@ func (x *EchomeetDelRecordsResp) ProtoReflect() protoreflect.Message {
// Deprecated: Use EchomeetDelRecordsResp.ProtoReflect.Descriptor instead. // Deprecated: Use EchomeetDelRecordsResp.ProtoReflect.Descriptor instead.
func (*EchomeetDelRecordsResp) Descriptor() ([]byte, []int) { 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 { func (x *EchomeetDelRecordsResp) GetIds() []uint64 {
@ -1808,7 +1642,7 @@ type EchomeetAliCallbackReq struct {
func (x *EchomeetAliCallbackReq) Reset() { func (x *EchomeetAliCallbackReq) Reset() {
*x = EchomeetAliCallbackReq{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1820,7 +1654,7 @@ func (x *EchomeetAliCallbackReq) String() string {
func (*EchomeetAliCallbackReq) ProtoMessage() {} func (*EchomeetAliCallbackReq) ProtoMessage() {}
func (x *EchomeetAliCallbackReq) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1833,7 +1667,7 @@ func (x *EchomeetAliCallbackReq) ProtoReflect() protoreflect.Message {
// Deprecated: Use EchomeetAliCallbackReq.ProtoReflect.Descriptor instead. // Deprecated: Use EchomeetAliCallbackReq.ProtoReflect.Descriptor instead.
func (*EchomeetAliCallbackReq) Descriptor() ([]byte, []int) { 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 { func (x *EchomeetAliCallbackReq) GetStatusCode() int32 {
@ -1880,7 +1714,7 @@ type EchomeetAliCallbackResult struct {
func (x *EchomeetAliCallbackResult) Reset() { func (x *EchomeetAliCallbackResult) Reset() {
*x = EchomeetAliCallbackResult{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1892,7 +1726,7 @@ func (x *EchomeetAliCallbackResult) String() string {
func (*EchomeetAliCallbackResult) ProtoMessage() {} func (*EchomeetAliCallbackResult) ProtoMessage() {}
func (x *EchomeetAliCallbackResult) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1905,7 +1739,7 @@ func (x *EchomeetAliCallbackResult) ProtoReflect() protoreflect.Message {
// Deprecated: Use EchomeetAliCallbackResult.ProtoReflect.Descriptor instead. // Deprecated: Use EchomeetAliCallbackResult.ProtoReflect.Descriptor instead.
func (*EchomeetAliCallbackResult) Descriptor() ([]byte, []int) { 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 { func (x *EchomeetAliCallbackResult) GetSentences() []*EchomeetAliSentence {
@ -1928,7 +1762,7 @@ type EchomeetAliSentence struct {
func (x *EchomeetAliSentence) Reset() { func (x *EchomeetAliSentence) Reset() {
*x = EchomeetAliSentence{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1940,7 +1774,7 @@ func (x *EchomeetAliSentence) String() string {
func (*EchomeetAliSentence) ProtoMessage() {} func (*EchomeetAliSentence) ProtoMessage() {}
func (x *EchomeetAliSentence) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1953,7 +1787,7 @@ func (x *EchomeetAliSentence) ProtoReflect() protoreflect.Message {
// Deprecated: Use EchomeetAliSentence.ProtoReflect.Descriptor instead. // Deprecated: Use EchomeetAliSentence.ProtoReflect.Descriptor instead.
func (*EchomeetAliSentence) Descriptor() ([]byte, []int) { 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 { func (x *EchomeetAliSentence) GetText() string {
@ -1999,7 +1833,7 @@ type EchomeetAliCallbackResp struct {
func (x *EchomeetAliCallbackResp) Reset() { func (x *EchomeetAliCallbackResp) Reset() {
*x = EchomeetAliCallbackResp{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -2011,7 +1845,7 @@ func (x *EchomeetAliCallbackResp) String() string {
func (*EchomeetAliCallbackResp) ProtoMessage() {} func (*EchomeetAliCallbackResp) ProtoMessage() {}
func (x *EchomeetAliCallbackResp) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -2024,7 +1858,7 @@ func (x *EchomeetAliCallbackResp) ProtoReflect() protoreflect.Message {
// Deprecated: Use EchomeetAliCallbackResp.ProtoReflect.Descriptor instead. // Deprecated: Use EchomeetAliCallbackResp.ProtoReflect.Descriptor instead.
func (*EchomeetAliCallbackResp) Descriptor() ([]byte, []int) { 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 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" + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x1a\n" +
"\baudiourl\x18\x02 \x01(\tR\baudiourl\"A\n" + "\baudiourl\x18\x02 \x01(\tR\baudiourl\"A\n" +
"\x14EchomeetUpRecordResp\x12)\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" + "\x14EchomeetStartTaskReq\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\"\n" + "\x02id\x18\x01 \x01(\x04R\x02id\x12\"\n" +
"\fformlanguage\x18\x02 \x01(\tR\fformlanguage\x12\x1e\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\x18\x05 \x01(\x04R\n" +
"templateid\x12\x10\n" + "templateid\x12\x10\n" +
"\x03tid\x18\x06 \x01(\tR\x03tid\x125\n" + "\x03tid\x18\x06 \x01(\tR\x03tid\x125\n" +
"\fservice_type\x18\a \x01(\x0e2\x12.TranscribeServiceR\vserviceType\x12#\n" + "\fservice_type\x18\a \x01(\x0e2\x12.TranscribeServiceR\vserviceType\"\x17\n" +
"\rsummary_model\x18\b \x01(\tR\fsummaryModel\"\x17\n" +
"\x15EchomeetStartTaskResp\"\xcf\x01\n" + "\x15EchomeetStartTaskResp\"\xcf\x01\n" +
"\x13EchomeetCallbackReq\x12\x12\n" + "\x13EchomeetCallbackReq\x12\x12\n" +
"\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\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" + "audio_info\x18\x05 \x01(\v2\n" +
".AudioInfoR\taudioInfo\x12#\n" + ".AudioInfoR\taudioInfo\x12#\n" +
"\rcallback_data\x18\x06 \x01(\tR\fcallbackData\"\x16\n" + "\rcallback_data\x18\x06 \x01(\tR\fcallbackData\"\x16\n" +
"\x14EchomeetCallbackResp\"\xd2\x01\n" + "\x14EchomeetCallbackResp\"\xad\x01\n" +
"\x12EchomeetSummaryReq\x12\x0e\n" + "\x12EchomeetSummaryReq\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x1e\n" + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x1e\n" +
"\n" + "\n" +
@ -2112,18 +1945,8 @@ const file_echomeet_echomeet_msg_proto_rawDesc = "" +
"\n" + "\n" +
"tolanguage\x18\a \x01(\tR\n" + "tolanguage\x18\a \x01(\tR\n" +
"tolanguage\x125\n" + "tolanguage\x125\n" +
"\fservice_type\x18\b \x01(\x0e2\x12.TranscribeServiceR\vserviceType\x12#\n" + "\fservice_type\x18\b \x01(\x0e2\x12.TranscribeServiceR\vserviceType\"\x15\n" +
"\rsummary_model\x18\t \x01(\tR\fsummaryModel\"\x15\n" + "\x13EchomeetSummaryResp\"'\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" +
"\x15EchomeetReadRecordReq\x12\x0e\n" + "\x15EchomeetReadRecordReq\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\"\x18\n" + "\x02id\x18\x01 \x01(\x04R\x02id\"\x18\n" +
"\x16EchomeetReadRecordResp\"\xb0\x01\n" + "\x16EchomeetReadRecordResp\"\xb0\x01\n" +
@ -2172,7 +1995,7 @@ func file_echomeet_echomeet_msg_proto_rawDescGZIP() []byte {
return file_echomeet_echomeet_msg_proto_rawDescData 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{ var file_echomeet_echomeet_msg_proto_goTypes = []any{
(*EchomeetGetTemplatesReq)(nil), // 0: EchomeetGetTemplatesReq (*EchomeetGetTemplatesReq)(nil), // 0: EchomeetGetTemplatesReq
(*EchomeetGetTemplatesResp)(nil), // 1: EchomeetGetTemplatesResp (*EchomeetGetTemplatesResp)(nil), // 1: EchomeetGetTemplatesResp
@ -2200,52 +2023,48 @@ var file_echomeet_echomeet_msg_proto_goTypes = []any{
(*EchomeetCallbackResp)(nil), // 23: EchomeetCallbackResp (*EchomeetCallbackResp)(nil), // 23: EchomeetCallbackResp
(*EchomeetSummaryReq)(nil), // 24: EchomeetSummaryReq (*EchomeetSummaryReq)(nil), // 24: EchomeetSummaryReq
(*EchomeetSummaryResp)(nil), // 25: EchomeetSummaryResp (*EchomeetSummaryResp)(nil), // 25: EchomeetSummaryResp
(*SummaryModelInfo)(nil), // 26: SummaryModelInfo (*EchomeetReadRecordReq)(nil), // 26: EchomeetReadRecordReq
(*EchomeetGetSummaryModelsReq)(nil), // 27: EchomeetGetSummaryModelsReq (*EchomeetReadRecordResp)(nil), // 27: EchomeetReadRecordResp
(*EchomeetGetSummaryModelsResp)(nil), // 28: EchomeetGetSummaryModelsResp (*EchomeetModifyRecordsReq)(nil), // 28: EchomeetModifyRecordsReq
(*EchomeetReadRecordReq)(nil), // 29: EchomeetReadRecordReq (*EchomeetModifyRecordsResp)(nil), // 29: EchomeetModifyRecordsResp
(*EchomeetReadRecordResp)(nil), // 30: EchomeetReadRecordResp (*EchomeetDelRecordsReq)(nil), // 30: EchomeetDelRecordsReq
(*EchomeetModifyRecordsReq)(nil), // 31: EchomeetModifyRecordsReq (*EchomeetDelRecordsResp)(nil), // 31: EchomeetDelRecordsResp
(*EchomeetModifyRecordsResp)(nil), // 32: EchomeetModifyRecordsResp (*EchomeetAliCallbackReq)(nil), // 32: EchomeetAliCallbackReq
(*EchomeetDelRecordsReq)(nil), // 33: EchomeetDelRecordsReq (*EchomeetAliCallbackResult)(nil), // 33: EchomeetAliCallbackResult
(*EchomeetDelRecordsResp)(nil), // 34: EchomeetDelRecordsResp (*EchomeetAliSentence)(nil), // 34: EchomeetAliSentence
(*EchomeetAliCallbackReq)(nil), // 35: EchomeetAliCallbackReq (*EchomeetAliCallbackResp)(nil), // 35: EchomeetAliCallbackResp
(*EchomeetAliCallbackResult)(nil), // 36: EchomeetAliCallbackResult (*DBEchoMeetTemplate)(nil), // 36: DBEchoMeetTemplate
(*EchomeetAliSentence)(nil), // 37: EchomeetAliSentence (*DBEchoMeetRecord)(nil), // 37: DBEchoMeetRecord
(*EchomeetAliCallbackResp)(nil), // 38: EchomeetAliCallbackResp (*ContextStruct)(nil), // 38: ContextStruct
(*DBEchoMeetTemplate)(nil), // 39: DBEchoMeetTemplate (TranscribeService)(0), // 39: TranscribeService
(*DBEchoMeetRecord)(nil), // 40: DBEchoMeetRecord (*RecognitionResult)(nil), // 40: RecognitionResult
(*ContextStruct)(nil), // 41: ContextStruct (*AudioInfo)(nil), // 41: AudioInfo
(TranscribeService)(0), // 42: TranscribeService
(*RecognitionResult)(nil), // 43: RecognitionResult
(*AudioInfo)(nil), // 44: AudioInfo
} }
var file_echomeet_echomeet_msg_proto_depIdxs = []int32{ var file_echomeet_echomeet_msg_proto_depIdxs = []int32{
39, // 0: EchomeetGetTemplatesResp.templates:type_name -> DBEchoMeetTemplate 36, // 0: EchomeetGetTemplatesResp.templates:type_name -> DBEchoMeetTemplate
39, // 1: EchomeetAddTemplateReq.template:type_name -> DBEchoMeetTemplate 36, // 1: EchomeetAddTemplateReq.template:type_name -> DBEchoMeetTemplate
39, // 2: EchomeetAddTemplateResp.template:type_name -> DBEchoMeetTemplate 36, // 2: EchomeetAddTemplateResp.template:type_name -> DBEchoMeetTemplate
39, // 3: EchomeetUpdateTemplateReq.template:type_name -> DBEchoMeetTemplate 36, // 3: EchomeetUpdateTemplateReq.template:type_name -> DBEchoMeetTemplate
39, // 4: EchomeetUpdateTemplateResp.template:type_name -> DBEchoMeetTemplate 36, // 4: EchomeetUpdateTemplateResp.template:type_name -> DBEchoMeetTemplate
40, // 5: EchomeetGetAllRecordsResp.records:type_name -> DBEchoMeetRecord 37, // 5: EchomeetGetAllRecordsResp.records:type_name -> DBEchoMeetRecord
40, // 6: EchomeetGetRecordsResp.records:type_name -> DBEchoMeetRecord 37, // 6: EchomeetGetRecordsResp.records:type_name -> DBEchoMeetRecord
40, // 7: EchomeetGetRecordResp.record:type_name -> DBEchoMeetRecord 37, // 7: EchomeetGetRecordResp.record:type_name -> DBEchoMeetRecord
40, // 8: EchomeetPutRecordsReq.records:type_name -> DBEchoMeetRecord 37, // 8: EchomeetPutRecordsReq.records:type_name -> DBEchoMeetRecord
41, // 9: EchomeetAddRecordReq.original:type_name -> ContextStruct 38, // 9: EchomeetAddRecordReq.original:type_name -> ContextStruct
40, // 10: EchomeetAddRecordResp.record:type_name -> DBEchoMeetRecord 37, // 10: EchomeetAddRecordResp.record:type_name -> DBEchoMeetRecord
40, // 11: EchomeetUpRecordResp.record:type_name -> DBEchoMeetRecord 37, // 11: EchomeetUpRecordResp.record:type_name -> DBEchoMeetRecord
42, // 12: EchomeetStartTaskReq.service_type:type_name -> TranscribeService 39, // 12: EchomeetStartTaskReq.service_type:type_name -> TranscribeService
43, // 13: EchomeetCallbackReq.result:type_name -> RecognitionResult 40, // 13: EchomeetCallbackReq.result:type_name -> RecognitionResult
44, // 14: EchomeetCallbackReq.audio_info:type_name -> AudioInfo 41, // 14: EchomeetCallbackReq.audio_info:type_name -> AudioInfo
42, // 15: EchomeetSummaryReq.service_type:type_name -> TranscribeService 39, // 15: EchomeetSummaryReq.service_type:type_name -> TranscribeService
26, // 16: EchomeetGetSummaryModelsResp.models:type_name -> SummaryModelInfo 37, // 16: EchomeetModifyRecordsResp.record:type_name -> DBEchoMeetRecord
40, // 17: EchomeetModifyRecordsResp.record:type_name -> DBEchoMeetRecord 33, // 17: EchomeetAliCallbackReq.Result:type_name -> EchomeetAliCallbackResult
36, // 18: EchomeetAliCallbackReq.Result:type_name -> EchomeetAliCallbackResult 34, // 18: EchomeetAliCallbackResult.Sentences:type_name -> EchomeetAliSentence
37, // 19: EchomeetAliCallbackResult.Sentences:type_name -> EchomeetAliSentence 19, // [19:19] is the sub-list for method output_type
20, // [20:20] is the sub-list for method output_type 19, // [19:19] is the sub-list for method input_type
20, // [20:20] is the sub-list for method input_type 19, // [19:19] is the sub-list for extension type_name
20, // [20:20] is the sub-list for extension type_name 19, // [19:19] is the sub-list for extension extendee
20, // [20:20] is the sub-list for extension extendee 0, // [0:19] is the sub-list for field type_name
0, // [0:20] is the sub-list for field type_name
} }
func init() { file_echomeet_echomeet_msg_proto_init() } func init() { file_echomeet_echomeet_msg_proto_init() }
@ -2260,7 +2079,7 @@ func file_echomeet_echomeet_msg_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_echomeet_echomeet_msg_proto_rawDesc), len(file_echomeet_echomeet_msg_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_echomeet_echomeet_msg_proto_rawDesc), len(file_echomeet_echomeet_msg_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 39, NumMessages: 36,
NumExtensions: 0, NumExtensions: 0,
NumServices: 0, NumServices: 0,
}, },

7
apps/services/services/home/main.go

@ -205,14 +205,17 @@ func (this *Service) InitSys() {
log.Infof("init sys.email success!") 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 { 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 { } else {
log.Infof("init sys.wechatpay success!") log.Infof("init sys.wechatpay success!")
} }
if err := alipay.OnInit(this.GetSettings().Sys["alipay"]); err != nil { 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 { } else {
log.Infof("init sys.alipay success!") log.Infof("init sys.alipay success!")
} }

29
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 不应判为限流")
}
}

70
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 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 / ...(保持调用方传入的标准) // 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) { func (this *AliTranslate) Translate(ctx context.Context, from, to string, texts []string) (results []string, err error) {
if len(texts) == 0 { if len(texts) == 0 {
return []string{}, nil return []string{}, nil
@ -107,16 +149,34 @@ func (this *AliTranslate) Translate(ctx context.Context, from, to string, texts
results[idx] = text results[idx] = text
return return
} }
r, e := this.translateOne(from, to, text) r, e := this.translateOneWithRetry(ctx, from, to, text)
results[idx] = r if e != nil {
results[idx] = text // 降级:保留原文,别让整批作废
errs[idx] = e errs[idx] = e
return
}
results[idx] = r
}(i, t) }(i, t)
} }
wg.Wait() wg.Wait()
failed, firstIdx := 0, -1
var firstErr error
for i, e := range errs { for i, e := range errs {
if e != nil { 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)
}
} }

4
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), TargetLanguage: volcengine.String(to),
TextList: volcengine.StringSlice(batch), TextList: volcengine.StringSlice(batch),
}); err != nil { }); err != nil {
return // 出错必须丢弃已翻好的部分:具名返回值裸 return 会带回一个长度不足的非 nil 切片,
// 调用方若据此判断「部分成功」,会把残缺译文当成完整结果用。
return nil, err
} }
for _, item := range resp.TranslationList { for _, item := range resp.TranslationList {
results = append(results, *item.Translation) results = append(results, *item.Translation)

34
apps/services/sys/pay/alipay/core.go

@ -2,6 +2,7 @@ package alipay
import ( import (
"context" "context"
"errors"
"net/url" "net/url"
) )
@ -26,24 +27,45 @@ type (
var defsys ISys var defsys ISys
func OnInit(config map[string]interface{}, option ...Option) (err error) { // ErrNotInited 支付宝未初始化。OnInit 失败时调用方可选择降级;此时包级函数返回本错误,而不是 panic。
defsys, err = newSys(newOptions(config, option...)) var ErrNotInited = errors.New("alipay 未初始化:配置缺失或密钥加载失败")
return
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) { func NewSys(option ...Option) (ISys, error) {
sys, err = newSys(newOptionsByOption(option...)) sys, err := newSys(newOptionsByOption(option...))
return if err != nil {
return nil, err
}
return sys, nil
} }
func GenerateOrderNo(prefix string) string { func GenerateOrderNo(prefix string) string {
if defsys == nil {
return ""
}
return defsys.GenerateOrderNo(prefix) return defsys.GenerateOrderNo(prefix)
} }
func CreateAppOrder(ctx context.Context, outTradeNo string, totalFee int64, description string, notifyurl string) (result string, err error) { 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) return defsys.CreateAppOrder(ctx, outTradeNo, totalFee, description, notifyurl)
} }
func DecryptWxPayNotify(values url.Values) (bool, error) { func DecryptWxPayNotify(values url.Values) (bool, error) {
if defsys == nil {
return false, ErrNotInited
}
return defsys.DecryptWxPayNotify(values) return defsys.DecryptWxPayNotify(values)
} }

33
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)
}
}

40
apps/services/sys/pay/wechat/core.go

@ -1,6 +1,9 @@
package wechat package wechat
import "context" import (
"context"
"errors"
)
type ( type (
// 预支付响应参数(返回给Flutter客户端) // 预支付响应参数(返回给Flutter客户端)
@ -58,24 +61,47 @@ type (
var defsys ISys var defsys ISys
func OnInit(config map[string]interface{}, option ...Option) (err error) { // ErrNotInited 微信支付未初始化。OnInit 失败时不再由库 Fatal,调用方可选择降级;
defsys, err = newSys(newOptions(config, option...)) // 此时包级函数返回本错误,而不是 panic。
return 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) { func NewSys(option ...Option) (ISys, error) {
sys, err = newSys(newOptionsByOption(option...)) sys, err := newSys(newOptionsByOption(option...))
return if err != nil {
return nil, err // 同上:不返回装着 nil 指针的非 nil 接口
}
return sys, nil
} }
func GenerateOrderNo(prefix string) string { func GenerateOrderNo(prefix string) string {
if defsys == nil {
return ""
}
return defsys.GenerateOrderNo(prefix) return defsys.GenerateOrderNo(prefix)
} }
func CreateAppOrder(ctx context.Context, outTradeNo string, totalFee int64, description string, notifyurl string) (result *AppPayParams, err error) { 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) return defsys.CreateAppOrder(ctx, outTradeNo, totalFee, description, notifyurl)
} }
func DecryptWxPayNotify(associatedData, nonce, ciphertext string) (*WxPayTransaction, error) { func DecryptWxPayNotify(associatedData, nonce, ciphertext string) (*WxPayTransaction, error) {
if defsys == nil {
return nil, ErrNotInited
}
return defsys.DecryptWxPayNotify(associatedData, nonce, ciphertext) return defsys.DecryptWxPayNotify(associatedData, nonce, ciphertext)
} }

36
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)
}
}

13
apps/services/sys/pay/wechat/wechat.go

@ -5,7 +5,6 @@ import (
"crypto/aes" "crypto/aes"
"crypto/cipher" "crypto/cipher"
"crypto/rsa" "crypto/rsa"
"yunyan/lego/sys/log"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
@ -32,7 +31,10 @@ func newSys(options Options) (sys *WeChat, err error) {
privateKey, err = utils.LoadPrivateKeyWithPath(options.PrivateKeyPath) privateKey, err = utils.LoadPrivateKeyWithPath(options.PrivateKeyPath)
} }
if err != nil { 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. 初始化客户端选项(推荐自动证书更新模式) // 2. 初始化客户端选项(推荐自动证书更新模式)
@ -48,11 +50,10 @@ func newSys(options Options) (sys *WeChat, err error) {
} }
// 3. 创建微信支付客户端 // 3. 创建微信支付客户端
sys.client, err = core.NewClient(ctx, opts...) if sys.client, err = core.NewClient(ctx, opts...); err != nil {
if err != nil { return nil, fmt.Errorf("创建微信支付客户端失败: %w", err)
log.Errorf("创建客户端失败: %v", err)
} }
return return sys, nil
} }
type WeChat struct { type WeChat struct {

Loading…
Cancel
Save