Browse Source

admin,console: 「会议模板」改成「会议配置」,一页管转写/总结/模板

后台恢复了会议转写段与总结段的配置入口——它们随「会议记录服务」页一起下线后,
只能直接改库(echomeet_orch),而那正是语音纪要整条流水线的服务选型来源。

- 路由 /meettemplates → /meetconfig,旧地址留重定向;**菜单 id 不变**:
  存量后台账号的 access 清单里存的是这个串,改 id 等于让这些账号丢入口;
- 一页全展开不分标签页,顶部一个应用选择器三块共用——各拉各的会出现
  「转写配的是 A、模板改的是 B」这种对不上的状态;
- 保存是声明式的,asr/llm 都是**指针**:缺哪个不动哪段。写成值类型的话
  「保存会议总结」会带一个空的 asr 数组把转写编排整段清空;
- 翻译段(kind=2) 本页不编辑但保存时必须原样保留,跟着 reconcile 会让纪要
  能转写、译文整体消失;
- 服务池查询是 app_name=? 不带 OR app_name='':作用域归一后没有全局默认层了,
  照旧版写回退查询会让一个应用读到另一个应用的服务选型,且不报任何错。

段→服务类别的映射由 TestEchoKindSvcCats 钉住:类别编号是 comm / console / admin
三处同写的,漏改一处的表现是某一段的下拉空空如也且不报错。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 3 weeks ago
parent
commit
5193b0e5e8
  1. 41
      CLAUDE.md
  2. 64
      apps/admin/app/components/MeetTemplatePanel.vue
  3. 383
      apps/admin/app/pages/meetconfig.vue
  4. 11
      apps/admin/app/pages/meettemplates.vue
  5. 9
      apps/admin/app/utils/menus.ts
  6. 274
      apps/services/modules/console/api_echomeet_orch.go
  7. 33
      apps/services/modules/console/echomeet_orch_test.go
  8. 9
      apps/services/modules/console/server.go

41
CLAUDE.md

@ -1515,7 +1515,7 @@ apps/products/regions/channelid 四维,而它们这四项**本来就是空的*
| 应用环境配置 | 先并入「服务与环境配置 → 应用参数」标签页;**2026-09-14 连标签页也下线**(见下文) | 凭据搬走后只剩应用自己的业务参数,而这些也搬进了 svc_config | | 应用环境配置 | 先并入「服务与环境配置 → 应用参数」标签页;**2026-09-14 连标签页也下线**(见下文) | 凭据搬走后只剩应用自己的业务参数,而这些也搬进了 svc_config |
| 第三方服务配置 | 改名「服务与环境配置」,成为凭据唯一出处;2026-09-14 菜单改回「第三方服务配置」 | — | | 第三方服务配置 | 改名「服务与环境配置」,成为凭据唯一出处;2026-09-14 菜单改回「第三方服务配置」 | — |
| Agent 配置 / 通话翻译配置 | **删除**(页面 + console 接口) | `agent_config`、`call_translate_rule` 在测试机与正式机上**都是 0 行**;通话翻译选路全在端侧 `language_manager.findBestMatchingProvider` | | Agent 配置 / 通话翻译配置 | **删除**(页面 + console 接口) | `agent_config`、`call_translate_rule` 在测试机与正式机上**都是 0 行**;通话翻译选路全在端侧 `language_manager.findBestMatchingProvider` |
| 会议记录服务 | **删除页面**,⚠️ 数据与运行时保留 | 见下方警告 | | 会议记录服务 | **删除页面**,⚠️ 数据与运行时保留;2026-09-17 以「会议配置」的形式回来(见下文) | 见下方警告 |
⚠️ **两张扁平 env 表的坑(这是本次改造的起因)**:同一个 ⚠️ **两张扁平 env 表的坑(这是本次改造的起因)**:同一个
`AZURE_SPEECH_REGION` / `OPENAI_API_KEY` / `VOLC_OPENSPEECH_APP_ID` / `XUNFEI_ASR_*` `AZURE_SPEECH_REGION` / `OPENAI_API_KEY` / `VOLC_OPENSPEECH_APP_ID` / `XUNFEI_ASR_*`
@ -1656,12 +1656,49 @@ console 启动跑、幂等、排在 `migrateGlobalScopeToApps` 之后):
⚠️ **「会议记录服务」删的只是后台入口,`echomeet_orch` 的数据与运行时都还在** ⚠️ **「会议记录服务」删的只是后台入口,`echomeet_orch` 的数据与运行时都还在**
(两台机各 4 行:`asrfile_alibaba`/`asrfile_azure` 识别、`mt_alibaba` 翻译、`llmv_qwen` 总结), (两台机各 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` ⚠️ 存量后台账号 access CSV 里的 `globalconfig`/`appconfig`/`agents`/`meetingsvc`/`calltranslate`
不再对应任何菜单项,权限编辑器里也勾不到了。只勾过 `appconfig` 的账号由 不再对应任何菜单项,权限编辑器里也勾不到了。只勾过 `appconfig` 的账号由
[auth.ts](apps/admin/app/stores/auth.ts) 的 `hasAccess` 兼容到 `svcconfig`,不会因为一次合并丢入口。 [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 新增) ## 第三方服务配置:服务端专用类别(2026-08-28 新增)
`svc_config`(后台「服务与环境配置 → 第三方服务」)默认是**面向客户端**的:[svcresolve.go](apps/services/modules/user/svcresolve.go) `svc_config`(后台「服务与环境配置 → 第三方服务」)默认是**面向客户端**的:[svcresolve.go](apps/services/modules/user/svcresolve.go)

64
apps/admin/app/components/MeetTemplatePanel.vue

@ -3,21 +3,11 @@
<!-- ══════════════ 列表视图:按 tid 一张卡,卡面呈现主语言(中文)版本 ══════════════ --> <!-- ══════════════ 列表视图:按 tid 一张卡,卡面呈现主语言(中文)版本 ══════════════ -->
<div v-if="view === 'list'"> <div v-if="view === 'list'">
<div class="flex flex-wrap items-start justify-between gap-3 mb-5"> <div class="flex flex-wrap items-start justify-between gap-3 mb-5">
<div> <p class="text-xs text-slate-400 max-w-3xl">
<h2 class="text-lg font-semibold text-slate-800">会议模板</h2> 会议纪要公共模板(source=public),<b>按应用各一套</b>。同一 tid 下每种语言一份,卡面展示
<p class="text-xs text-slate-400 mt-1"> {{ PRIMARY_LANG }} 版本。修改后各服务约 10 分钟内自动同步。
会议纪要公共模板(source=public),<b>按应用各一套</b>。同一 tid 下每种语言一份,卡面展示 </p>
{{ PRIMARY_LANG }} 版本。修改后各服务约 10 分钟内自动同步。 <button class="btn btn-primary btn-sm text-white shrink-0" :disabled="!scopeApp" @click="openCreate()">+ 新增模板</button>
</p>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-slate-500">应用</span>
<select v-model="scopeApp" class="select select-bordered select-sm min-w-40" @change="loadData">
<option value="" disabled>请选择应用</option>
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
</select>
<button class="btn btn-primary btn-sm text-white" :disabled="!scopeApp" @click="openCreate()">+ 新增模板</button>
</div>
</div> </div>
<!-- 筛选条 --> <!-- 筛选条 -->
@ -274,13 +264,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { ALL_LANG_OPTIONS, langName } from '~/composables/useLangOptions' import { ALL_LANG_OPTIONS, langName } from '~/composables/useLangOptions'
definePageMeta({ title: '会议模板' })
interface MeetTemplate { interface MeetTemplate {
id: number id: number
title: string title: string
@ -315,30 +303,14 @@ const SYNC_FIELDS = [
{ key: 'template', label: '模板内容 template', translated: true }, { key: 'template', label: '模板内容 template', translated: true },
] as const ] as const
const { webApi, consoleApi } = useApi() const { webApi } = useApi()
const { success, error } = useToast() const { success, error } = useToast()
// 2026-09-14 起会议模板按应用隔离(与第三方服务同一套规矩)。应用名来源同 appversion.vue: // 2026-09-14 起会议模板按应用隔离(与第三方服务同一套规矩)。应用名由「会议配置」页统一下发,
// products/list ∪ apps/list,无权读时走 getmyapps。用的是应用名(app_name),运行时 ANALYZE_APP_NAME 就是它。 // 这里不再自己拉一份应用列表——同一页上三块配置共用一个应用选择器,各拉各的会出现
const scopeApp = ref('') // 「转写配的是 A、模板改的是 B」这种对不上的状态。
const scopeApps = ref<string[]>([]) const props = defineProps<{ appName: string }>()
async function loadScopeApps() { const scopeApp = computed(() => props.appName)
const names = new Set<string>()
const [products, deploys] = await Promise.all([
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => [] as Array<{ name: string }>),
consoleApi<Array<{ app_name?: string; name: string }>>('apps/list', {}).catch(() => [] as Array<{ app_name?: string; name: string }>),
])
;(products ?? []).forEach((p) => p.name && names.add(p.name))
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name))
if (!names.size) {
try {
const d = await webApi<{ apps: Array<{ name: string; app_name?: string }> }>('getmyapps', {})
;(d?.apps ?? []).forEach((a) => { const n = a.app_name || a.name; if (n) names.add(n) })
} catch { /* 空列表,页面提示先选应用 */ }
}
scopeApps.value = [...names].sort()
if (!scopeApp.value && scopeApps.value.length) scopeApp.value = scopeApps.value[0]!
}
const view = ref<'list' | 'detail'>('list') const view = ref<'list' | 'detail'>('list')
const rows = ref<MeetTemplate[]>([]) const rows = ref<MeetTemplate[]>([])
@ -697,10 +669,16 @@ async function delWholeTid() {
} }
} }
onMounted(async () => { // 切换应用要退回列表:详情停在上一个应用的模板上,保存时会带着新应用名写过去。
await loadScopeApps() watch(scopeApp, () => {
await loadData() view.value = 'list'
current.value = null
curLang.value = ''
detailCache.clear()
loadData()
}) })
onMounted(() => { loadData() })
</script> </script>
<style scoped> <style scoped>

383
apps/admin/app/pages/meetconfig.vue

@ -0,0 +1,383 @@
<template>
<div>
<!-- 页头 + 应用选择:三块配置共用一个作用域 -->
<div class="flex flex-wrap items-start justify-between gap-3 mb-4">
<div>
<h2 class="text-lg font-semibold text-slate-800">会议配置</h2>
<p class="text-xs text-slate-400 mt-1 max-w-3xl">
语音纪要这条流水线的应用级参数:<b>会议转写</b>用哪家录音文件识别、<b>会议总结</b>用哪个大模型、
以及<b>会议模板</b>。都<b>按应用各一套</b>,互不干涉。
</p>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-slate-500">应用</span>
<select v-model="scopeApp" class="select select-bordered select-sm min-w-40">
<option value="" disabled>请选择应用</option>
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
</select>
</div>
</div>
<div v-if="!scopeApp" class="panel py-16 text-center text-slate-400 text-sm">
请先在右上角选择应用。
</div>
<template v-else>
<!-- 上半区:左「会议转写」右「会议总结」,窄屏自动叠成一列 -->
<div class="topgrid">
<!-- ══════════════ 会议转写 ══════════════ -->
<section class="panel p-5">
<div class="flex flex-wrap items-start justify-between gap-3 mb-1">
<div>
<div class="text-sm font-semibold text-slate-700">会议转写(录音文件识别)</div>
<div class="text-xs text-slate-400 mt-1 max-w-3xl">
从<b>第三方服务配置</b>里已配好的「录音文件识别」类服务中选。
<b>选一个就只用这一个;选两个及以上,排在后面的是兜底</b>——
第一家提交失败(凭据失效、欠费、报错)时自动换下一家。
另外,服务若填了支持语言,遇到它不支持的源语言也会直接顺延。
</div>
</div>
<button class="btn btn-sm shrink-0" :disabled="!addableAsr.length || saving" @click="openAdd()">
+ 添加转写服务
</button>
</div>
<div v-if="loading" class="py-14 text-center"><span class="loading loading-spinner loading-sm"></span></div>
<template v-else>
<div v-if="!asrPick.length" class="empty mt-4">
还没有选转写服务,语音纪要会直接转写失败。
<span v-if="!svcOf('asr').length">
当前应用下也没有可选的「录音文件识别」服务,请先到<NuxtLink class="link" to="/serviceconfig">第三方服务配置</NuxtLink>添加并启用。
</span>
</div>
<ol v-else class="picklist mt-4">
<li v-for="(id, i) in asrPick" :key="id" class="pick">
<span class="rank" :class="{ main: i === 0 }">{{ i === 0 ? '主用' : '兜底 ' + i }}</span>
<div class="min-w-0 flex-1">
<div class="pick-name">
{{ svcName(id) }}
<span class="pick-id">{{ id }}</span>
<span v-if="!svcMap.get(id)" class="chip chip-warn">该服务已停用或不存在</span>
</div>
<div class="pick-sub">
{{ svcProvider(id) }} ·
<template v-if="svcLangs(id)">支持 {{ svcLangs(id).length }} 种语言</template>
<template v-else>未限定支持语言(由引擎判断)</template>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<button class="btn btn-ghost btn-xs" :disabled="i === 0" title="上移" @click="moveAsr(i, -1)">↑</button>
<button class="btn btn-ghost btn-xs" :disabled="i === asrPick.length - 1" title="下移" @click="moveAsr(i, 1)">↓</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="asrPick.splice(i, 1)">移除</button>
</div>
</li>
</ol>
<div class="savebar">
<button class="btn btn-primary btn-sm text-white" :disabled="saving || !asrDirty" @click="saveAsr()">保存会议转写</button>
<button class="btn btn-ghost btn-sm" :disabled="saving || !asrDirty" @click="resetAsr()">放弃修改</button>
<span v-if="asrDirty" class="text-xs text-amber-600">有未保存的改动</span>
</div>
</template>
</section>
<!-- ══════════════ 会议总结 ══════════════ -->
<section class="panel p-5">
<div class="text-sm font-semibold text-slate-700">会议总结(大模型)</div>
<div class="text-xs text-slate-400 mt-1 max-w-3xl">
从<b>第三方服务配置</b>里已配好的「文本大模型 / 多媒体大模型」类服务中选一个,用于生成纪要总结。
这一段<b>没有兜底</b>:选中的服务不可用时总结直接失败。
</div>
<div v-if="loading" class="py-14 text-center"><span class="loading loading-spinner loading-sm"></span></div>
<template v-else>
<div class="form-control max-w-xl mt-4">
<label class="label"><span class="label-text font-medium">总结模型</span></label>
<select v-model="llmPick" class="select select-bordered">
<option value="">不配置(不生成总结)</option>
<option v-for="s in svcOf('llm')" :key="s.id" :value="s.id">{{ s.name }}({{ s.id }})</option>
<!-- 已选服务在服务池里找不到(被停用/删除)时仍要列出来,否则下拉会静默跳到「不配置」 -->
<option v-if="llmPick && !svcMap.get(llmPick)" :value="llmPick">{{ llmPick }}(已停用或不存在)</option>
</select>
</div>
<div v-if="!svcOf('llm').length" class="empty mt-4">
当前应用下没有可选的大模型服务,请先到<NuxtLink class="link" to="/serviceconfig">第三方服务配置</NuxtLink>添加并启用。
</div>
<div v-if="extraLlm.length" class="alert alert-warning py-2 text-xs mt-4">
<span>
该应用还另有 {{ extraLlm.length }} 个已配置的总结服务({{ extraLlm.join('、') }})。
保存后只保留上面选中的这一个,其余会从编排里移除(不影响第三方服务配置里的服务本身)。
<template v-if="clientChoice">
⚠️ 该应用开着「客户端可指定总结模型」,这些服务现在可能正被 App 端选用,移除后客户端只剩这一个可选。
</template>
</span>
</div>
<div class="savebar">
<button class="btn btn-primary btn-sm text-white" :disabled="saving || !llmSavable" @click="saveLlm()">保存会议总结</button>
<button class="btn btn-ghost btn-sm" :disabled="saving || !llmDirty" @click="resetLlm()">放弃修改</button>
<span v-if="llmDirty" class="text-xs text-amber-600">有未保存的改动</span>
</div>
</template>
</section>
</div>
<!-- 区域分叉:本页只编辑「全区域」那一层,有分叉就说清楚,别让后台显示的和线上跑的不是一回事 -->
<div v-if="regionRows" class="alert alert-warning py-2 text-xs mt-4">
<span>
该应用另有 {{ regionRows }} 条<b>按区域</b>的编排,本页只编辑「全区域」这一层。
区域分叉优先于全区域,对应区域的部署实际跑的是那几条,如需调整请直接改库
(表 <span class="font-mono">echomeet_orch</span>)。
</span>
</div>
<!-- ══════════════ 会议模板:占满下方,内容多了整页往下滚 ══════════════ -->
<section class="tplsec">
<h3 class="tplsec-title">会议模板</h3>
<MeetTemplatePanel :app-name="scopeApp" />
</section>
</template>
<!-- 添加转写服务 -->
<div v-if="addOpen" class="modal modal-open">
<div class="modal-box max-w-lg">
<h3 class="font-bold text-base mb-1">添加转写服务</h3>
<p class="text-xs text-slate-400 mb-4">加进列表末尾作为兜底,可再用上下箭头调整顺序。</p>
<div class="form-control">
<select v-model="addSvc" class="select select-bordered">
<option value="" disabled>选择一个服务</option>
<option v-for="s in addableAsr" :key="s.id" :value="s.id">{{ s.name }}({{ s.id }})</option>
</select>
</div>
<div class="modal-action">
<button class="btn btn-ghost" @click="addOpen = false">取消</button>
<button class="btn btn-primary text-white" :disabled="!addSvc" @click="doAdd()">添加</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '会议配置' })
const { webApi, consoleApi } = useApi()
const { success, error } = useToast()
// 编排类别(与后端 comm.EchoKindASR/MT/LLM 同一套数字)。翻译段(2)本页不编辑,
// 后端 saveechoorch 也不会碰它——它同样是语音纪要流水线的一环,跟着一起重置会让译文整体消失。
const KIND_ASR = 1
const KIND_LLM = 3
interface Orch {
row_id: number
app_name: string
region: number
kind: number
svc_id: string
priority: number
is_default: boolean
enabled: boolean
}
interface SvcView { id: string; name: string; provider: string; categories: string; languages: string }
const loading = ref(false)
const saving = ref(false)
// 应用名来源同 appversion.vue:products/list ∪ apps/list,无权读时走 getmyapps。
// 用的是应用名(app_name),运行时 ANALYZE_APP_NAME 就是它。
const scopeApp = ref('')
const scopeApps = ref<string[]>([])
async function loadScopeApps() {
const names = new Set<string>()
const [products, deploys] = await Promise.all([
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => [] as Array<{ name: string }>),
consoleApi<Array<{ app_name?: string; name: string }>>('apps/list', {}).catch(() => [] as Array<{ app_name?: string; name: string }>),
])
;(products ?? []).forEach((p) => p.name && names.add(p.name))
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name))
if (!names.size) {
try {
const d = await webApi<{ apps: Array<{ name: string; app_name?: string }> }>('getmyapps', {})
;(d?.apps ?? []).forEach((a) => { const n = a.app_name || a.name; if (n) names.add(n) })
} catch { /* 空列表,页面提示先选应用 */ }
}
scopeApps.value = [...names].sort()
if (!scopeApp.value && scopeApps.value.length) scopeApp.value = scopeApps.value[0]!
}
const available = ref<SvcView[]>([])
const kindCats = ref<Record<string, number[]>>({ '1': [8], '3': [5, 9] })
const regionRows = ref(0)
// 作用域开关:允许客户端自己指定总结模型。本页不编辑它(没有后台入口,需要时改库),
// 但要读出来——开着的时候「只保留一个总结服务」这件事对 App 端是有感知的。
const clientChoice = ref(false)
// 库里当前的样子,用于「有没有改动」与「放弃修改」;编辑态是 asrPick / llmPick。
const asrSaved = ref<string[]>([])
const llmSaved = ref('')
const extraLlm = ref<string[]>([])
const asrPick = ref<string[]>([])
const llmPick = ref('')
const addOpen = ref(false)
const addSvc = ref('')
const svcMap = computed(() => {
const m = new Map<string, SvcView>()
for (const s of available.value) m.set(s.id, s)
return m
})
function svcName(id: string) { return svcMap.value.get(id)?.name ?? id }
function svcProvider(id: string) { return svcMap.value.get(id)?.provider ?? '未知服务商' }
function svcLangs(id: string) {
return (svcMap.value.get(id)?.languages ?? '').split(',').map((x) => x.trim()).filter(Boolean)
}
function catsOf(s: SvcView) {
return (s.categories || '').split(',').map((x) => Number(x.trim())).filter((n) => !Number.isNaN(n))
}
// 某段可选的服务 = 服务池里 categories 命中该段类别的
function svcOf(seg: 'asr' | 'llm') {
const want = kindCats.value[String(seg === 'asr' ? KIND_ASR : KIND_LLM)] ?? []
return available.value.filter((s) => catsOf(s).some((c) => want.includes(c)))
}
const addableAsr = computed(() => svcOf('asr').filter((s) => !asrPick.value.includes(s.id)))
const asrDirty = computed(() => asrPick.value.join(',') !== asrSaved.value.join(','))
const llmDirty = computed(() => llmPick.value !== llmSaved.value)
// 下拉没动过、但库里还躺着多余的总结行时也允许保存——保存正是为了把那些清掉。
// 这种情况不算「未保存的改动」(用户什么都没改),所以只放开按钮,不打那条提示。
const llmSavable = computed(() => llmDirty.value || extraLlm.value.length > 0)
function resetAsr() { asrPick.value = asrSaved.value.slice() }
function resetLlm() { llmPick.value = llmSaved.value }
function openAdd() {
addSvc.value = ''
addOpen.value = true
}
function doAdd() {
if (addSvc.value && !asrPick.value.includes(addSvc.value)) asrPick.value.push(addSvc.value)
addOpen.value = false
}
function moveAsr(i: number, dir: number) {
const list = asrPick.value
const j = i + dir
if (j < 0 || j >= list.length) return
const tmp = list[i]!
list[i] = list[j]!
list[j] = tmp
}
async function loadOrch() {
if (!scopeApp.value) { available.value = []; asrSaved.value = []; llmSaved.value = ''; return }
loading.value = true
try {
const d = await webApi<{
items: Orch[]
available: SvcView[]
kind_cats: Record<string, number[]>
other_regions: Record<string, number>
setting?: { llm_client_choice?: boolean }
}>('getechoorch', { app_name: scopeApp.value })
available.value = d?.available ?? []
clientChoice.value = !!d?.setting?.llm_client_choice
if (d?.kind_cats) kindCats.value = d.kind_cats
regionRows.value = Object.values(d?.other_regions ?? {}).reduce((a, b) => a + b, 0)
const items = (d?.items ?? []).filter((o) => o.enabled)
asrSaved.value = items.filter((o) => o.kind === KIND_ASR).sort((a, b) => a.priority - b.priority).map((o) => o.svc_id)
// 总结取默认模型;没有标默认就按优先级第一个(与运行时 PickLLM 的口径一致)。
const llms = items.filter((o) => o.kind === KIND_LLM).sort((a, b) => a.priority - b.priority)
const def = llms.find((o) => o.is_default) ?? llms[0]
llmSaved.value = def?.svc_id ?? ''
extraLlm.value = llms.filter((o) => o.svc_id !== llmSaved.value).map((o) => o.svc_id)
resetAsr()
resetLlm()
} catch (e: any) {
error(e?.message ?? '加载失败')
available.value = []
} finally {
loading.value = false
}
}
async function saveAsr() {
saving.value = true
try {
await webApi('saveechoorch', { app_name: scopeApp.value, asr: asrPick.value })
success('会议转写已保存')
await loadOrch()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
saving.value = false
}
}
async function saveLlm() {
saving.value = true
try {
await webApi('saveechoorch', { app_name: scopeApp.value, llm: llmPick.value })
success('会议总结已保存')
await loadOrch()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
saving.value = false
}
}
watch(scopeApp, () => { loadOrch() })
onMounted(async () => {
await loadScopeApps()
await loadOrch()
})
</script>
<style scoped>
.panel { background:#fff; border:1px solid #f1f5f9; border-radius:16px; box-shadow:0 1px 2px rgba(0,0,0,0.04); }
/* 上半区两栏:左转写、右总结。两块内容长短不一,用 stretch 让卡片等高,视觉上是一排。
1100px 以下叠成一列——两栏挤在窄屏上会让服务名和按钮换行成一团。 */
.topgrid { display:grid; grid-template-columns:1fr 1fr; gap:16px; align-items:stretch; }
@media (max-width: 1100px) { .topgrid { grid-template-columns:1fr; } }
/* 两块内容长短不一,保存条用 margin-top:auto 压到卡片底部,左右才对得齐 */
.topgrid > .panel { display:flex; flex-direction:column; }
.topgrid .savebar { margin-top:auto; }
/* 会议模板占满下方剩余空间,超出就整页往下滚(不给它自己开滚动条:
模板卡片网格本来就高,嵌套滚动会出现两条滚动条、还会把筛选条挤在半空) */
.tplsec { margin-top:20px; }
.tplsec-title { font-size:15px; font-weight:600; color:#1e293b; margin-bottom:12px; }
.picklist { display:flex; flex-direction:column; gap:8px; }
.pick {
display:flex; align-items:center; gap:12px; padding:11px 13px;
border:1px solid #eef2f7; border-radius:12px; background:#fff;
}
.rank {
flex-shrink:0; font-size:11px; font-weight:600; padding:2px 9px; border-radius:999px;
background:#f1f5f9; color:#64748b; white-space:nowrap;
}
.rank.main { background:#e0e7ff; color:#4338ca; }
.pick-name { font-size:13.5px; font-weight:600; color:#1e293b; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
.pick-id { font-size:11px; color:#94a3b8; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-weight:400; }
.pick-sub { font-size:11.5px; color:#94a3b8; margin-top:2px; }
.chip { display:inline-block; font-size:11px; padding:1px 7px; border-radius:999px; background:#f1f5f9; color:#64748b; white-space:nowrap; }
.chip-warn { background:#fef3c7; color:#b45309; }
.empty { font-size:12.5px; color:#94a3b8; background:#f8fafc; border:1px dashed #e2e8f0; border-radius:12px; padding:18px; }
.link { color:#4338ca; text-decoration:underline; }
.savebar { display:flex; align-items:center; gap:10px; margin-top:20px; padding-top:16px; border-top:1px solid #f1f5f9; }
</style>

11
apps/admin/app/pages/meettemplates.vue

@ -0,0 +1,11 @@
<template>
<div class="py-16 text-center text-slate-400 text-sm">正在跳转到「会议配置」…</div>
</template>
<script setup lang="ts">
// 2026-09-17:「会议模板」并入「会议配置」(/meetconfig)的一个标签页,页面本体已拆成
// components/MeetTemplateTab.vue。这里只留重定向——后台里这个地址被收藏/直接输入的不在少数,
// 直接删文件会变成 404(同「全局环境配置」下线时的处理)。
definePageMeta({ title: '会议配置' })
onMounted(() => { navigateTo('/meetconfig', { replace: true }) })
</script>

9
apps/admin/app/utils/menus.ts

@ -70,13 +70,14 @@ export const MENU_GROUPS: MenuGroup[] = [
// · Agent 配置(agents) / 通话翻译配置(calltranslate) —— agent_config、call_translate_rule // · Agent 配置(agents) / 通话翻译配置(calltranslate) —— agent_config、call_translate_rule
// 两张表在测试机与正式机上**都是 0 行**,客户端的通话翻译选路全在端侧 // 两张表在测试机与正式机上**都是 0 行**,客户端的通话翻译选路全在端侧
// (language_manager.findBestMatchingProvider),后台这两页从未产生过数据。 // (language_manager.findBestMatchingProvider),后台这两页从未产生过数据。
// · 会议记录服务(meetingsvc) —— 按要求下架。⚠️ 它背后的 echomeet_orch 仍有 4 行在跑 // · 会议记录服务(meetingsvc) —— 2026-09-12 下架,2026-09-17 以「会议配置」的形式回来:
// (语音纪要的识别/翻译/总结选型),**数据与运行时保留**,只是后台不再提供编辑入口; // 转写/总结两段的服务选型并进了会议模板那一页(翻译段仍只能改库)。
// 要换服务商得直接改库(见 services/modules/echomeet/orch_resolver.go)。
{ 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: '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: '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: '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 表)。 // 版本控制 / 游客显隐控制:按应用配、不分渠道(存 app_release 表)。
// 这两组开关原先埋在「SaaS 管理 → 渠道分发」里按渠道各存一份,同一个开关有 8 处能改, // 这两组开关原先埋在「SaaS 管理 → 渠道分发」里按渠道各存一份,同一个开关有 8 处能改,
// 客户端只读一处,出过「后台开了游客登录但 App 里没有」的事故,故拆到这里单独管。 // 客户端只读一处,出过「后台开了游客登录但 App 里没有」的事故,故拆到这里单独管。

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

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

9
apps/services/modules/console/server.go

@ -318,6 +318,15 @@ func (this *serverComp) handleWeb(c *gin.Context) {
case "api_syncmeettemplate": case "api_syncmeettemplate":
this.syncMeetTemplate(c) this.syncMeetTemplate(c)
return 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 主库,与选中应用无关。 // 第三方服务配置(svcconfig):服务字段定义/默认值 + 区域字段分叉(值覆盖/专属字段/停用字段) + 多区域同步,存 console 主库,与选中应用无关。
case "api_getsvcconfigs": case "api_getsvcconfigs":
this.getSvcConfigs(c) this.getSvcConfigs(c)

Loading…
Cancel
Save