diff --git a/CLAUDE.md b/CLAUDE.md
index 8a02b303..d3af5b4d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1515,7 +1515,7 @@ apps/products/regions/channelid 四维,而它们这四项**本来就是空的*
| 应用环境配置 | 先并入「服务与环境配置 → 应用参数」标签页;**2026-09-14 连标签页也下线**(见下文) | 凭据搬走后只剩应用自己的业务参数,而这些也搬进了 svc_config |
| 第三方服务配置 | 改名「服务与环境配置」,成为凭据唯一出处;2026-09-14 菜单改回「第三方服务配置」 | — |
| Agent 配置 / 通话翻译配置 | **删除**(页面 + console 接口) | `agent_config`、`call_translate_rule` 在测试机与正式机上**都是 0 行**;通话翻译选路全在端侧 `language_manager.findBestMatchingProvider` |
-| 会议记录服务 | **删除页面**,⚠️ 数据与运行时保留 | 见下方警告 |
+| 会议记录服务 | **删除页面**,⚠️ 数据与运行时保留;2026-09-17 以「会议配置」的形式回来(见下文) | 见下方警告 |
⚠️ **两张扁平 env 表的坑(这是本次改造的起因)**:同一个
`AZURE_SPEECH_REGION` / `OPENAI_API_KEY` / `VOLC_OPENSPEECH_APP_ID` / `XUNFEI_ASR_*`
@@ -1656,12 +1656,49 @@ console 启动跑、幂等、排在 `migrateGlobalScopeToApps` 之后):
⚠️ **「会议记录服务」删的只是后台入口,`echomeet_orch` 的数据与运行时都还在**
(两台机各 4 行:`asrfile_alibaba`/`asrfile_azure` 识别、`mt_alibaba` 翻译、`llmv_qwen` 总结),
它是语音纪要整条流水线的服务选型来源,见
-[orch_resolver.go](apps/services/modules/echomeet/orch_resolver.go)。**要换服务商现在只能直接改库。**
+[orch_resolver.go](apps/services/modules/echomeet/orch_resolver.go)。
+**转写段与总结段的后台入口已于 2026-09-17 在「会议配置」页恢复(见下节);翻译段仍只能改库。**
⚠️ 存量后台账号 access CSV 里的 `globalconfig`/`appconfig`/`agents`/`meetingsvc`/`calltranslate`
不再对应任何菜单项,权限编辑器里也勾不到了。只勾过 `appconfig` 的账号由
[auth.ts](apps/admin/app/stores/auth.ts) 的 `hasAccess` 兼容到 `svcconfig`,不会因为一次合并丢入口。
+## 「会议模板」改成「会议配置」:一页管转写/总结/模板(2026-09-17)
+
+后台菜单项 `meettemplate` 的**标签与路由**变了(`/meettemplates` → `/meetconfig`,旧地址留重定向),
+**菜单 id 没变**——存量后台账号的 access 清单里存的是这个串,改 id 等于让这些账号丢入口。
+**一页全展开,不分标签页**:上半区两栏(左「会议转写」、右「会议总结」,1100px 以下叠成一列),
+下半区「会议模板」占满剩余空间、内容多了整页往下滚(**刻意不给模板区单独开滚动条**——
+模板卡片网格本来就高,嵌套滚动会出现两条滚动条、还会把筛选条挤在半空)。
+顶部**一个**应用选择器三块共用(都按应用隔离,各拉各的会出现「转写配的是 A、模板改的是 B」
+这种对不上的状态):
+
+| 区块 | 配什么 | 存哪 |
+|---|---|---|
+| 会议转写 | 有序选若干个「录音文件识别」(类别 8) 服务 | `echomeet_orch` kind=1,顺序 = `priority` 升序 |
+| 会议总结 | 单选一个「文本/多媒体大模型」(类别 5/9) 服务 | `echomeet_orch` kind=3,选中的那条 `is_default=true` |
+| 会议模板 | 原「会议模板」页原样搬来 | `echomeet_template`,`source='public'` |
+
+模板页本体已拆成组件 [MeetTemplatePanel.vue](apps/admin/app/components/MeetTemplatePanel.vue)(应用名由父页面以 prop 下发),
+页面壳是 [meetconfig.vue](apps/admin/app/pages/meetconfig.vue),后端是重新写的
+[api_echomeet_orch.go](apps/services/modules/console/api_echomeet_orch.go)(`api_getechoorch` / `api_saveechoorch`)。
+
+- **「选一个就用一个、选两个第二个兜底」是运行时本来就有的行为**,这一页只是把它的入口补回来:
+ `PickASR` 按 priority 升序取第一个「已启用 + 支持源语言」的,提交失败由 `NextASR` 顺延下一家
+ (2026-09-15 线上 `asrfile_alibaba` 的 key 失效、排后面的 `asrfile_azure` 完好却一次没被碰,
+ 就是因为当时还没有 `NextASR`)。**总结段没有兜底**:`PickLLM` 只认 `is_default`,选中的不可用就直接失败。
+- ⚠️ **保存是声明式的,两段各自独立**:请求里 `asr` / `llm` 都是**指针**,缺哪个就不动哪段。
+ 写成值类型的话「保存会议总结」会带一个空的 `asr` 数组过来,把转写编排整段清空。
+- ⚠️ **翻译段 (kind=2) 本页不编辑,保存时必须原样保留**:它同样是语音纪要流水线的一环,
+ 跟着一起 reconcile 会让纪要能转写、**译文整体消失**。
+- ⚠️ **服务池查询是 `app_name=?`,不带 `OR app_name=''`**:2026-09-14 作用域归一后没有全局默认层了,
+ 照旧版写回退查询会让一个应用读到另一个应用(或迁移前残留的全局行)的服务选型,且不报任何错。
+- 本页只编辑 `region=0`(全区域)那一层。区域分叉(`region>0`)优先级更高、仍由运行时消费,
+ 接口把它们的行数回报给前端并在页面上明说——后台显示的若与线上跑的不是一回事,排查会被带偏。
+- 段 → 服务类别的映射在 `echoKindSvcCats`(转写 8、翻译 3、总结 5+9),
+ `TestEchoKindSvcCats` 钉着它:类别编号是三处同写的(comm / console / admin 前端 `SCAT`),
+ 漏改一处的表现是**某一段的下拉空空如也且不报任何错**,很容易去怀疑数据。
+
## 第三方服务配置:服务端专用类别(2026-08-28 新增)
`svc_config`(后台「服务与环境配置 → 第三方服务」)默认是**面向客户端**的:[svcresolve.go](apps/services/modules/user/svcresolve.go)
diff --git a/apps/admin/app/components/MeetTemplatePanel.vue b/apps/admin/app/components/MeetTemplatePanel.vue
index 5b7a9d1c..4125f39a 100644
--- a/apps/admin/app/components/MeetTemplatePanel.vue
+++ b/apps/admin/app/components/MeetTemplatePanel.vue
@@ -3,21 +3,11 @@
-
-
会议模板
-
- 会议纪要公共模板(source=public),按应用各一套。同一 tid 下每种语言一份,卡面展示
- {{ PRIMARY_LANG }} 版本。修改后各服务约 10 分钟内自动同步。
-
-
-
- 应用
-
-
-
+
+ 会议纪要公共模板(source=public),按应用各一套。同一 tid 下每种语言一份,卡面展示
+ {{ PRIMARY_LANG }} 版本。修改后各服务约 10 分钟内自动同步。
+
+
@@ -274,13 +264,11 @@
diff --git a/apps/admin/app/pages/meettemplates.vue b/apps/admin/app/pages/meettemplates.vue
new file mode 100644
index 00000000..76dd2b04
--- /dev/null
+++ b/apps/admin/app/pages/meettemplates.vue
@@ -0,0 +1,11 @@
+
+ 正在跳转到「会议配置」…
+
+
+
diff --git a/apps/admin/app/utils/menus.ts b/apps/admin/app/utils/menus.ts
index ce7dee24..540515ac 100644
--- a/apps/admin/app/utils/menus.ts
+++ b/apps/admin/app/utils/menus.ts
@@ -70,13 +70,14 @@ export const MENU_GROUPS: MenuGroup[] = [
// · Agent 配置(agents) / 通话翻译配置(calltranslate) —— agent_config、call_translate_rule
// 两张表在测试机与正式机上**都是 0 行**,客户端的通话翻译选路全在端侧
// (language_manager.findBestMatchingProvider),后台这两页从未产生过数据。
- // · 会议记录服务(meetingsvc) —— 按要求下架。⚠️ 它背后的 echomeet_orch 仍有 4 行在跑
- // (语音纪要的识别/翻译/总结选型),**数据与运行时保留**,只是后台不再提供编辑入口;
- // 要换服务商得直接改库(见 services/modules/echomeet/orch_resolver.go)。
+ // · 会议记录服务(meetingsvc) —— 2026-09-12 下架,2026-09-17 以「会议配置」的形式回来:
+ // 转写/总结两段的服务选型并进了会议模板那一页(翻译段仍只能改库)。
{ id: 'svcconfig', label: '第三方服务配置', to: '/serviceconfig', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
{ id: 'appgoods', label: '应用商品配置', to: '/appgoods', icon: 'M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z' },
{ id: 'compute', label: '会员与算力', to: '/compute', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
- { id: 'meettemplate', label: '会议模板', to: '/meettemplates', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
+ // 2026-09-17「会议模板」→「会议配置」:同一页里管会议转写(录音文件识别)、会议总结(大模型)、会议模板三块。
+ // ⚠️ 菜单 id 仍是 meettemplate:存量后台账号的 access 清单里存的是这个串,改 id 等于让这些账号丢入口。
+ { id: 'meettemplate', label: '会议配置', to: '/meetconfig', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
// 版本控制 / 游客显隐控制:按应用配、不分渠道(存 app_release 表)。
// 这两组开关原先埋在「SaaS 管理 → 渠道分发」里按渠道各存一份,同一个开关有 8 处能改,
// 客户端只读一处,出过「后台开了游客登录但 App 里没有」的事故,故拆到这里单独管。
diff --git a/apps/services/modules/console/api_echomeet_orch.go b/apps/services/modules/console/api_echomeet_orch.go
new file mode 100644
index 00000000..4d2e501e
--- /dev/null
+++ b/apps/services/modules/console/api_echomeet_orch.go
@@ -0,0 +1,274 @@
+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})
+}
diff --git a/apps/services/modules/console/echomeet_orch_test.go b/apps/services/modules/console/echomeet_orch_test.go
new file mode 100644
index 00000000..6d03ed78
--- /dev/null
+++ b/apps/services/modules/console/echomeet_orch_test.go
@@ -0,0 +1,33 @@
+package console
+
+import "testing"
+
+// 会议配置页的两段各自对应哪些服务类别,是后台下拉能不能列出服务的唯一依据,
+// 而类别 id 同时写在三处(comm.SvcCat* / console svcCat* / admin 前端 SCAT)。
+// 这里把编排段 → 类别的映射钉死:改类别编号而漏改这张表,表现是后台某一段的下拉
+// **空空如也且不报任何错**(服务都在、只是筛不出来),排查时很容易去怀疑数据。
+func TestEchoKindSvcCats(t *testing.T) {
+ want := map[int32][]int32{
+ echoKindASR: {8}, // 录音文件识别 → 会议转写
+ echoKindMT: {3}, // 机器翻译(本页不编辑,运行时仍在用)
+ echoKindLLM: {5, 9}, // 文本大模型 + 多媒体大模型 → 会议总结
+ }
+ if len(echoKindSvcCats) != len(want) {
+ t.Fatalf("编排段数量变了: got %d want %d", len(echoKindSvcCats), len(want))
+ }
+ for kind, cats := range want {
+ got := echoKindSvcCats[kind]
+ if len(got) != len(cats) {
+ t.Fatalf("kind=%d 类别数不符: got %v want %v", kind, got, cats)
+ }
+ for i := range cats {
+ if got[i] != cats[i] {
+ t.Fatalf("kind=%d 类别不符: got %v want %v", kind, got, cats)
+ }
+ }
+ }
+ // 前端 meetconfig.vue 的兜底常量与这里同值,接口拿不到 kind_cats 时按它筛。
+ if echoKindASR != 1 || echoKindLLM != 3 {
+ t.Fatalf("编排段编号变了: asr=%d llm=%d", echoKindASR, echoKindLLM)
+ }
+}
diff --git a/apps/services/modules/console/server.go b/apps/services/modules/console/server.go
index a364fb99..e688c882 100644
--- a/apps/services/modules/console/server.go
+++ b/apps/services/modules/console/server.go
@@ -318,6 +318,15 @@ func (this *serverComp) handleWeb(c *gin.Context) {
case "api_syncmeettemplate":
this.syncMeetTemplate(c)
return
+ // 会议配置 · 服务编排:会议转写(kind=1)/会议总结(kind=3) 各选哪些第三方服务。
+ // 只引用 svc_config 里的服务 id,不存凭据;按 app_name 隔离(没有全局默认层)。
+ // 2026-09-12 随「会议记录服务」页一起删过,2026-09-17 并进「会议配置」页恢复。
+ case "api_getechoorch":
+ this.getEchoOrch(c)
+ return
+ case "api_saveechoorch":
+ this.saveEchoOrch(c)
+ return
// 第三方服务配置(svcconfig):服务字段定义/默认值 + 区域字段分叉(值覆盖/专属字段/停用字段) + 多区域同步,存 console 主库,与选中应用无关。
case "api_getsvcconfigs":
this.getSvcConfigs(c)