package console import ( "sort" "strconv" "strings" "time" "yunyan/comm" "yunyan/lego/sys/postgres" "yunyan/pb" "github.com/gin-gonic/gin" ) // ============================ 会议配置 · 服务编排 (echomeet orch) ============================ // // admin「会议配置」页的后端(2026-09-17 恢复;上一版「会议记录服务」页于 2026-09-12 随菜单收敛 // 一起删掉了后台入口,但 echomeet_orch 的数据与运行时一直在跑,换服务商只能直接改库)。 // // 编排**不存凭据**,只引用「第三方服务配置」(svc_config) 里配好的服务 id: // · 会议转写 kind=1 → 服务类别「录音文件识别」(svcCatASRFile) // · 会议总结 kind=3 → 服务类别「文本大模型」(svcCatLLM) / 「多媒体大模型」(svcCatLLMVision) // 运行时选路见 modules/echomeet/providers.go 的 PickASR/NextASR/PickLLM: // 转写按 priority 升序取第一个「已启用 + 支持源语言」的,提交失败再顺延下一个,所以 // **配两家 = 第二家兜底**;总结取 is_default,没有则按 priority 第一个。 // // ⚠️ 作用域是 app_name,**没有「全局默认」层了**(2026-09-14 作用域归一,见 migrate_scope.go): // 每个应用一套、互不干涉,app_name 空一律拒绝。别再照旧版写 `OR app_name=''` 的回退查询—— // 那会让一个应用读到另一个应用(或迁移前残留的全局行)的服务选型,且不报任何错。 // // 本页只编辑 region=0(全区域)那一层。区域分叉(region>0)仍由运行时的 scopeRank 消费, // 页面不编辑但会把行数回报给前端,免得后台显示的与线上跑的不是一回事。 // 本页编辑的区域层:0 = 全区域。区域分叉留给运行时,后台不再提供按区域编排的入口。 const echoOrchEditRegion int32 = 0 // echoOrchScopeOK app_name 必填 + 账号权限(同 svcScopeOK / meetTplScopeOK 的规矩)。 func (this *serverComp) echoOrchScopeOK(c *gin.Context, appName string) bool { if appName == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "app_name 必填:会议配置按应用隔离,请先选择应用") return false } return this.requireAppNameScope(c, appName) } // echoOrchSvcView 供前端选服务/显示名的精简服务视图(**不含凭据**)。 type echoOrchSvcView struct { Id string `json:"id"` Name string `json:"name"` Provider string `json:"provider"` Categories string `json:"categories"` // 逗号分隔类别,前端按 kind_cats 筛 Languages string `json:"languages"` // 支持语言(逗号分隔 BCP-47),空=不限;转写选路按它跳过不支持的 } // echoOrchAvailableSvcs 该应用作用域下启用中的服务(凭据一律不出这个函数)。 func (this *serverComp) echoOrchAvailableSvcs(appName string) []echoOrchSvcView { rows := make([]*ThirdSvcConfig, 0) if err := postgres.Find(comm.TableSvcConfig, &rows, "app_name=? AND enable=true", appName); err != nil { return nil } out := make([]echoOrchSvcView, 0, len(rows)) for _, r := range rows { langs := "" for _, f := range r.Fields { if f.Key == "languages" { langs = f.DefValue break } } out = append(out, echoOrchSvcView{ Id: r.Id, Name: r.Name, Provider: r.Provider, Categories: r.Categories, Languages: langs, }) } sort.SliceStable(out, func(i, j int) bool { return out[i].Id < out[j].Id }) return out } // echoOrchSvcAllowed 校验 svc_id 在该应用下可见、已启用,且类别与 kind 匹配。 func (this *serverComp) echoOrchSvcAllowed(appName, svcId string, kind int32) (bool, string) { wantCats := echoKindSvcCats[kind] if len(wantCats) == 0 { return false, "未知的编排类别" } rows := make([]*ThirdSvcConfig, 0) if err := postgres.Find(comm.TableSvcConfig, &rows, "app_name=? AND id=?", appName, svcId); err != nil { return false, err.Error() } for _, r := range rows { if !r.Enable { continue } for _, cat := range strings.Split(r.Categories, ",") { for _, want := range wantCats { if strings.TrimSpace(cat) == strconv.Itoa(int(want)) { return true, "" } } } } return false, "所选服务「" + svcId + "」不存在于该应用、已停用,或类别与该段不匹配" } // getEchoOrch 列出某应用的会议服务编排 + 可选服务池。 func (this *serverComp) getEchoOrch(c *gin.Context) { var req struct { AppName string `json:"app_name"` } _ = c.ShouldBindJSON(&req) req.AppName = strings.TrimSpace(req.AppName) if !this.echoOrchScopeOK(c, req.AppName) { return } all := make([]*EchoOrchestration, 0) if err := postgres.Find(comm.TableEchomeetOrch, &all, "app_name=?", req.AppName); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } items := make([]*EchoOrchestration, 0, len(all)) // 区域分叉本页不编辑,但要如实回报行数:后台显示的若与线上跑的不是一回事,排查会被带偏。 otherRegions := map[string]int{} for _, r := range all { if r.Region == echoOrchEditRegion { items = append(items, r) continue } otherRegions[strconv.Itoa(int(r.Region))]++ } sort.SliceStable(items, func(i, j int) bool { if items[i].Kind != items[j].Kind { return items[i].Kind < items[j].Kind } return items[i].Priority < items[j].Priority }) setting := &EchoOrchSetting{} if err := postgres.FindOne(comm.TableEchomeetOrchSetting, setting, "app_name=? AND region=?", req.AppName, echoOrchEditRegion); err != nil { setting = &EchoOrchSetting{AppName: req.AppName, Region: echoOrchEditRegion} // 未设置 → 零值 } writeOK(c, gin.H{ "items": items, "setting": setting, "available": this.echoOrchAvailableSvcs(req.AppName), // 各段对应的服务池类别,前端据此从「可选服务」里筛出每段能选的服务。 "kind_cats": gin.H{ strconv.Itoa(int(echoKindASR)): echoKindSvcCats[echoKindASR], strconv.Itoa(int(echoKindMT)): echoKindSvcCats[echoKindMT], strconv.Itoa(int(echoKindLLM)): echoKindSvcCats[echoKindLLM], }, "other_regions": otherRegions, }) } // saveEchoOrch 声明式保存:请求给的就是该应用**最终**的转写顺序与总结模型,后端按差异增删改。 // // 为什么不是逐行 CRUD:这一页只有「选哪几家、谁在前」两个决定,声明式保存让页面所见即库内所存, // 不会出现「界面上删掉了但库里还留着一行 enabled=true」这种后台与运行时不一致的中间态。 // // ⚠️ **翻译段(kind=2) 不在本页范围内,必须原样保留**:它同样是语音纪要流水线的一环 // (见 orch_resolver.go),这里若跟着一起 reconcile 就会把翻译服务清空,表现为纪要能转写、 // 译文整体消失。 func (this *serverComp) saveEchoOrch(c *gin.Context) { // ⚠️ 两段都是**指针**:nil = 本次不动这一段。页面上转写与总结是两个标签页各自保存, // 用值类型的话「保存总结」会带着一个空的 asr 数组过来,把转写编排整段清空。 var req struct { AppName string `json:"app_name"` ASR *[]string `json:"asr"` // 转写:按顺序,第 1 个主用,其余依次兜底 LLM *string `json:"llm"` // 总结:单选,空串 = 不配总结 } if err := c.ShouldBindJSON(&req); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } req.AppName = strings.TrimSpace(req.AppName) if !this.echoOrchScopeOK(c, req.AppName) { return } // 去重并保序;顺带校验每个 svc 的可用性与类别,有一个不合法就整体拒绝(别写一半)。 want := map[int32][]string{} kinds := make([]int32, 0, 2) if req.ASR != nil { asr := make([]string, 0, len(*req.ASR)) seen := map[string]bool{} for _, id := range *req.ASR { id = strings.TrimSpace(id) if id == "" || seen[id] { continue } if ok, msg := this.echoOrchSvcAllowed(req.AppName, id, echoKindASR); !ok { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } seen[id] = true asr = append(asr, id) } want[echoKindASR] = asr kinds = append(kinds, echoKindASR) } if req.LLM != nil { llm := strings.TrimSpace(*req.LLM) if llm != "" { if ok, msg := this.echoOrchSvcAllowed(req.AppName, llm, echoKindLLM); !ok { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } want[echoKindLLM] = []string{llm} } kinds = append(kinds, echoKindLLM) } if len(kinds) == 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "asr / llm 至少给一段") return } old := make([]*EchoOrchestration, 0) if err := postgres.Find(comm.TableEchomeetOrch, &old, "app_name=? AND region=?", req.AppName, echoOrchEditRegion); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } oldBy := map[int32]map[string]*EchoOrchestration{} for _, r := range old { if oldBy[r.Kind] == nil { oldBy[r.Kind] = map[string]*EchoOrchestration{} } oldBy[r.Kind][r.SvcId] = r } now := time.Now().Unix() for _, kind := range kinds { keep := map[string]bool{} for i, id := range want[kind] { keep[id] = true row := oldBy[kind][id] priority := int32((i + 1) * 10) isDefault := kind == echoKindLLM && i == 0 // 总结取 is_default;转写不用这个标记 if row != nil { // 已有行:只动顺序/启用/默认,cost_hint、client_visible 等运营填过的字段原样保留。 row.Priority = priority row.Enabled = true row.IsDefault = isDefault row.Updatetime = now if err := postgres.Save(comm.TableEchomeetOrch, row); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } continue } add := &EchoOrchestration{ AppName: req.AppName, Region: echoOrchEditRegion, Kind: kind, SvcId: id, Priority: priority, IsDefault: isDefault, Enabled: true, Createtime: now, Updatetime: now, } if err := postgres.Insert(comm.TableEchomeetOrch, add); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } } for id, row := range oldBy[kind] { if keep[id] { continue } if err := postgres.Delete(comm.TableEchomeetOrch, "row_id=?", row.RowId); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } } } broadcastConfigChanged(comm.ConfigKindEchomeetOrch, "update", echoOrchEditRegion) // 通知 echomeet 运行时重载编排 writeOK(c, gin.H{"saved": kinds}) }