You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

696 lines
38 KiB

package console
import (
"sort"
"strconv"
"strings"
"time"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/lego/sys/postgres"
"yunyan/pb"
"github.com/gin-gonic/gin"
)
// ============================ 第三方服务商模板域 (svctemplate) ============================
//
// 数据驱动模板:管理员在「第三方服务配置」新增服务时选一个模板,自动带出字段(可再增删)。
// 模板存 console 主库(postgres.GetSys()),启动时幂等 seed 内置常见服务商(见 seedSvcTemplates)。
// 模板字段只作 schema 预设,不存真实密钥、不加密。
// ============================== 模板 CRUD ==============================
// getSvcTemplates 列出全部服务商模板;category>0 时按类别过滤,按 (category, sort, id) 排序返回。
func (this *serverComp) getSvcTemplates(c *gin.Context) {
var req struct {
Category int32 `json:"category"`
}
_ = c.ShouldBindJSON(&req)
items := make([]*ThirdSvcTemplate, 0)
var err error
if req.Category > 0 {
err = postgres.Find(comm.TableThirdSvcTemplate, &items, "category=?", req.Category)
} else {
err = postgres.Find(comm.TableThirdSvcTemplate, &items, "")
}
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].Category != items[j].Category {
return items[i].Category < items[j].Category
}
if items[i].Sort != items[j].Sort {
return items[i].Sort < items[j].Sort
}
return items[i].Id < items[j].Id
})
writeOK(c, gin.H{"items": items})
}
// addSvcTemplate 新增自定义服务商模板(Builtin=false)。
func (this *serverComp) addSvcTemplate(c *gin.Context) {
var m ThirdSvcTemplate
if err := c.ShouldBindJSON(&m); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
if strings.TrimSpace(m.Id) == "" || strings.TrimSpace(m.Name) == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "模板ID 和 名称 必填")
return
}
exist := &ThirdSvcTemplate{}
if err := postgres.FindOne(comm.TableThirdSvcTemplate, exist, "id=?", m.Id); err == nil {
writeErr(c, pb.ErrorCode_ReqParameterError, "模板ID 已存在: "+m.Id)
return
}
m.Builtin = false
now := time.Now().UnixMilli()
m.Createtime = now
m.Updatetime = now
if err := postgres.Insert(comm.TableThirdSvcTemplate, &m); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, &m)
}
// updateSvcTemplate 更新服务商模板(按 id)。保留旧记录的 Builtin/Createtime。
func (this *serverComp) updateSvcTemplate(c *gin.Context) {
var m ThirdSvcTemplate
if err := c.ShouldBindJSON(&m); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
if strings.TrimSpace(m.Id) == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "模板ID 必填")
return
}
old := &ThirdSvcTemplate{}
if err := postgres.FindOne(comm.TableThirdSvcTemplate, old, "id=?", m.Id); err != nil {
writeErr(c, pb.ErrorCode_DBError, "模板不存在: "+m.Id)
return
}
m.Builtin = old.Builtin
m.Createtime = old.Createtime
m.Updatetime = time.Now().UnixMilli()
if err := postgres.Save(comm.TableThirdSvcTemplate, &m); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, &m)
}
// delSvcTemplate 删除服务商模板(按 id)。删除 builtin 允许,但下次启动会被 seed 重新补回。
func (this *serverComp) delSvcTemplate(c *gin.Context) {
var req struct {
Id string `json:"id"`
}
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Id) == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填")
return
}
if err := postgres.Delete(comm.TableThirdSvcTemplate, "id=?", req.Id); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, gin.H{"id": req.Id})
}
// ============================== 内置模板 seed ==============================
// 服务类别常量(与 admin 前端 SCAT / serviceconfig.vue 一致)。
// 一个服务实例的 categories 是逗号分隔多值,可同属多类(如 GPT-4o 同属「文本大模型」+「多媒体大模型」)。
const (
svcCatSTT int32 = 1 // 语音识别(实时/短语音,通话翻译在用)
svcCatTTS int32 = 2 // 语音合成
svcCatMT int32 = 3 // 机器翻译
svcCatAST int32 = 4 // 端到端语音翻译
svcCatLLM int32 = 5 // 文本大模型
svcCatStorage int32 = 6 // 对象存储
svcCatSTS int32 = 7 // 端到端语音对话
svcCatASRFile int32 = 8 // 录音文件识别(会议记录离线转写,接口/凭据区别于实时 STT)
svcCatLLMVision int32 = 9 // 多媒体大模型(能识别图像,用于带图的会议总结)
svcCatMCP int32 = 10 // MCP 服务(模型上下文协议;url/type/tools 三字段,按区域 fork,全应用共享)
svcCatIdVerify int32 = 11 // 身份证实名核验(服务端专用:凭据是云账号主 AK/SK,comm.IsServerOnlySvc 保证永不下发客户端)
svcCatAppParams int32 = 12 // 应用参数(非凭据的应用级业务参数,如内购客服 QQ;2026-09-14 从业务库 config 表搬来,见 migrate_appparams.go)
)
// sf 构造一个模板字段(sort 由调用顺序在 tpl 内自动填)。
func sf(key, desc string, encrypted bool) SvcField {
return SvcField{Key: key, Description: desc, Encrypted: encrypted}
}
// sfd 构造一个带默认值的模板字段(如回调相对路径——由代码路由决定,运营通常不用改)。
func sfd(key, desc string, encrypted bool, defValue string) SvcField {
return SvcField{Key: key, Description: desc, Encrypted: encrypted, DefValue: defValue}
}
// sfe 构造一个带「下发键名」的模板字段:值会以 envKey 为名出现在 user_getappconfig 的 env 里。
// ⚠️ envKey 是已发布客户端读的键名(搜客户端 AppConfig.env / AppConfig.cred 的第三个参数),改一个字母老包就读不到。
func sfe(key, desc string, encrypted bool, envKey, defValue string) SvcField {
return SvcField{Key: key, Description: desc, Encrypted: encrypted, DefValue: defValue, EnvKey: envKey}
}
// tpl 构造一个内置模板(Builtin=true,字段 sort 按传入顺序自动编号)。
func tpl(id, provider, name string, category, order int32, fields ...SvcField) ThirdSvcTemplate {
for i := range fields {
fields[i].Sort = int32(i)
}
return ThirdSvcTemplate{
Id: id, Provider: provider, Name: name,
Category: category, Sort: order, Fields: fields, Builtin: true,
}
}
// audioOutputCats 需要输出音频、须配置「支持语言 + 默认语言」的服务类别(TTS/AST/STS)。
var audioOutputCats = map[int32]bool{svcCatTTS: true, svcCatAST: true, svcCatSTS: true}
// asrLangCats 需要「支持语言(多选)」但不需要默认语言的识别类:录音文件识别。
// 语义是「该服务能识别哪些语种」,会议记录编排按它做选路过滤(源语言不支持则跳到下一个)。
var asrLangCats = map[int32]bool{svcCatASRFile: true}
// 录音文件识别全语种集(本系统 BCP-47 全集,与 admin useLangOptions.ts 一致)。
// 字节大模型 / Google / Azure 三家在此全集内均全量支持。
const asrLangsAll = "zh-CN,zh-TW,zh-HK,yue-HK,en-US,en-GB,ja-JP,ko-KR,id-ID,es-ES,es-MX,pt-BR,pt-PT,de-DE,fr-FR,it-IT,nl-NL,pl-PL,ru-RU,uk-UA,tr-TR,vi-VN,th-TH,ms-MY,fil-PH,ar-SA"
// asrFileDefaultLangs 各录音识别服务商官方支持语种默认值(2026-08 核对),seed 时预填 languages 字段,运营可再增删。
// 字节大模型 / Google / Azure 三家在本全集内全支持。
//
// 阿里按 qwen-audio-3.0-asr-flash-filetrans 的官方语种表填(需在服务里配 workspace_id 才会用该模型)。
// 本全集 26 个码里它覆盖 24 个,缺的两个是 tr-TR、uk-UA——这两种只出现在 Qwen-ASR 系列的语种表中,
// 该模型的表里没有,未实测前不放开(放开的后果不是报错而是静默返回空转写)。
// ⚠️ 若某个部署的阿里服务没填 workspace_id,它跑的还是 paraformer-v2(只支持
// zh/yue/en/ja/ko/de/fr/ru 八种),必须手工把该服务的 languages 收窄回去,否则会拿到空转写。
var asrFileDefaultLangs = map[string]string{
"bytedance": asrLangsAll,
"google": asrLangsAll,
"azure": asrLangsAll,
"alibaba": "zh-CN,zh-TW,zh-HK,yue-HK,en-US,en-GB,ja-JP,ko-KR,id-ID,es-ES,es-MX,pt-BR,pt-PT,de-DE,fr-FR,it-IT,nl-NL,pl-PL,ru-RU,vi-VN,th-TH,ms-MY,fil-PH,ar-SA", // qwen-audio-3.0-asr-flash-filetrans
}
// builtinSvcTemplates 内置服务商模板清单(参考 polychat-agent 的服务商与字段归纳,可后续增改)。
func builtinSvcTemplates() []ThirdSvcTemplate {
list := []ThirdSvcTemplate{
// STT 语音识别
tpl("stt_azure", "azure", "微软 Azure STT", svcCatSTT, 0,
sf("subscription_key", "订阅密钥", true), sf("region", "区域/终结点,如 eastus", false), sf("language", "语言代码,如 zh-CN", false)),
tpl("stt_openai", "openai", "OpenAI Whisper", svcCatSTT, 1,
sf("api_key", "API Key", true), sf("model", "模型,如 whisper-1", false), sf("language", "语言代码", false)),
tpl("stt_volcengine", "volcengine", "火山引擎 STT", svcCatSTT, 2,
sf("appid", "应用 AppID", false), sf("token", "访问 Token", true), sf("cluster", "集群标识", false)),
tpl("stt_alibaba", "alibaba", "阿里云 STT", svcCatSTT, 3,
sf("access_key_id", "AccessKey ID", true), sf("access_key_secret", "AccessKey Secret", true), sf("app_key", "项目 AppKey", false)),
// TTS 语音合成
// Azure TTS 只要凭据两件套:音色不再配在服务上——同一个 TTS 服务要服务多个 agent、
// 多种语言,音色是「按 agent 的语言+性别选」的(见 comm/ttsvoice.go 音色表 + agent 的 voice_map),
// 配在服务上只会与 agent 打架。语速/音调同理,属于调用方参数不是服务凭据。
tpl("tts_azure", "azure", "微软 Azure TTS", svcCatTTS, 0,
sf("subscription_key", "订阅密钥", true), sf("region", "区域 id,如 eastus / southeastasia", false)),
tpl("tts_openai", "openai", "OpenAI TTS", svcCatTTS, 1,
sf("api_key", "API Key", true), sf("model", "模型,如 tts-1", false), sf("voice", "音色,如 alloy", false)),
tpl("tts_volcengine", "volcengine", "火山引擎 TTS", svcCatTTS, 2,
sf("appid", "应用 AppID", false), sf("token", "访问 Token", true), sf("cluster", "集群标识", false)),
tpl("tts_alibaba", "alibaba", "阿里云 TTS", svcCatTTS, 3,
sf("access_key_id", "AccessKey ID", true), sf("access_key_secret", "AccessKey Secret", true)),
tpl("tts_elevenlabs", "elevenlabs", "ElevenLabs TTS", svcCatTTS, 4,
sf("api_key", "API Key", true), sf("voice_id", "音色 ID", false)),
// MT 机器翻译
tpl("mt_azure", "azure", "微软 Azure 翻译", svcCatMT, 0,
sf("subscription_key", "订阅密钥", true), sf("region", "区域,如 eastasia", false)),
// ⚠️ 字段须对齐运行时 sys 客户端:Google 翻译走服务账号 JSON(sys/google/translate),
// 火山翻译走 AK/SK(sys/bytedance/translate)——不是 api_key。
tpl("mt_google", "google", "Google 翻译", svcCatMT, 1,
sf("json", "服务账号 JSON(内容或路径)", true)),
tpl("mt_doubao", "doubao", "豆包(火山)翻译", svcCatMT, 2,
sf("access_key", "火山 AccessKey", true), sf("secret_key", "火山 SecretKey", true), sf("region", "区域(可选,默认 cn-north-1)", false)),
tpl("mt_alibaba", "alibaba", "阿里云 翻译", svcCatMT, 3,
sf("access_key_id", "AccessKey ID", true), sf("access_key_secret", "AccessKey Secret", true)),
// AST 端到端语音翻译
tpl("ast_azure", "azure", "微软 Azure AST", svcCatAST, 0,
sf("subscription_key", "订阅密钥", true), sf("region", "区域,如 eastus", false)),
tpl("ast_volcengine", "volcengine", "火山引擎 AST", svcCatAST, 1,
sf("appid", "应用 AppID", false), sf("token", "访问 Token", true), sf("cluster", "集群标识", false)),
// 阿里的端到端翻译走百炼 qwen3.5-livetranslate:凭据是 DashScope 的 API Key,
// **不是**云账号的 AccessKey ID/Secret。原模板照后者建,与线上实际用的那套对不上——
// 客户端读的是 ALIBABA_OPENSPEECH_APP_KEY(即这里的 app_key),AK/SK 一个都用不到。
// 图片翻译(客户端 alibaba_image_translation_service)用的也是这把百炼 API Key,只是走 qwen-vl 视觉模型,
// 所以它的模型名/接口地址挂在这里而不是 llmv_qwen——那条是服务端会议总结用的 qwen-plus,
// base_url 是不带路径的根地址,两边对不上。老下发键名 ALIBABA_VL_MODEL / ALIBABA_VL_ENDPOINT 原先
// 哪张表都没有,客户端一直用代码里的默认值;现在后台能配了,留空仍走客户端默认。
tpl("ast_alibaba", "alibaba", "阿里云 AST", svcCatAST, 2,
sf("app_key", "百炼 API Key", true),
sf("app_id", "旧版模型名/AppId(仅旧档使用)", false),
sf("ws_url", "WebSocket 地址(国内站/国际站不同)", false),
sfe("vl_model", "图片翻译视觉模型(留空=qwen-vl-plus)", false, "ALIBABA_VL_MODEL", ""),
sfe("vl_endpoint", "图片翻译接口地址(留空=DashScope 兼容模式 chat/completions)", false, "ALIBABA_VL_ENDPOINT", "")),
// LLM 文本大模型
tpl("llm_openai", "openai", "OpenAI", svcCatLLM, 0,
sf("api_key", "API Key", true), sf("model", "模型,如 gpt-4o", false), sf("base_url", "接口地址(可选)", false), sf("temperature", "生成温度 0~2", false), sf("max_tokens", "最大输出 Token", false)),
tpl("llm_doubao", "doubao", "豆包(火山引擎)", svcCatLLM, 1,
sf("api_key", "API Key", true), sf("endpoint_id", "推理接入点 ID", false), sf("base_url", "接口地址", false)),
tpl("llm_anthropic", "anthropic", "Anthropic Claude", svcCatLLM, 2,
sf("api_key", "API Key", true), sf("model", "模型,如 claude-sonnet-4", false),
sfd("base_url", "接口地址", false, "https://api.anthropic.com/v1")),
tpl("llm_deepseek", "deepseek", "DeepSeek", svcCatLLM, 3,
sf("api_key", "API Key", true), sf("model", "模型,如 deepseek-chat", false), sf("base_url", "接口地址", false)),
// base_url 带默认值:客户端拿到配置就得能直接发请求,缺地址等于这条服务下发了个半成品
// (同厂商的 llmv_qwen 一直有 base_url,llm_qwen 漏了,客户端只能自己写死 dashscope 地址)。
tpl("llm_qwen", "qwen", "通义千问(阿里)", svcCatLLM, 4,
sf("api_key", "API Key", true), sf("model", "模型,如 qwen-max", false),
sfd("base_url", "接口地址", false, "https://dashscope.aliyuncs.com/compatible-mode/v1")),
// 录音文件识别(会议记录离线转写;字段对齐 modules/echomeet 各 provider 的运行时凭据)
//
// ⚠️ 回调只存**相对路径**,不存完整 URL:一个服务(尤其全局默认的)会被所有应用共用,
// 而每个应用/部署的对外地址不同。完整回调地址由运行时拼:<该应用的对外地址> + callback_path。
// 默认值取自 echomeet 的反射路由(home 服务前缀 /api/home/ + <module>_<method小写>),
// 改 echomeet 回调方法名或网关前缀时要同步这里。Google/Azure 走轮询、无回调,故无此字段。
tpl("asrfile_bytedance", "bytedance", "字节跳动 录音识别大模型", svcCatASRFile, 0,
sf("appid", "应用 AppID", false), sf("token", "访问 Token", true),
sfd("callback_path", "转写回调路径(相对),运行时与应用对外地址拼成完整回调", false, "/api/home/echomeet_backcall")),
// workspace_id 决定用哪个模型:填了走 qwen-audio-3.0-asr-flash-filetrans(27 语种,
// 端点 https://<ws>.cn-beijing.maas.aliyuncs.com);留空退回老的 paraformer-v2(仅 8 语种)。
// 存量配置只有 api_key,升级后不填也能继续跑,但语言白名单必须相应只留老的 11 个码。
tpl("asrfile_alibaba", "alibaba", "阿里云百炼 录音识别", svcCatASRFile, 1,
sf("api_key", "百炼 ApiKey(sk-xxx)", true),
sf("workspace_id", "工作空间 ID(ws-xxx),填了才启用新模型 qwen-audio-3.0,留空用旧 paraformer-v2", false),
sfd("callback_path", "转写回调路径(相对),运行时与应用对外地址拼成完整回调", false, "/api/home/echomeet_alibackcall")),
tpl("asrfile_google", "google", "Google Cloud Speech", svcCatASRFile, 2,
sf("json", "服务账号 JSON(内容或路径)", true)),
tpl("asrfile_azure", "azure", "微软 Azure 语音", svcCatASRFile, 3,
sf("subscription_key", "订阅密钥", true), sf("region", "区域,如 eastasia", false)),
// 多媒体大模型(能识别图像,用于带图会议总结;一个全能模型服务可同时勾选「文本大模型」)
tpl("llmv_openai", "openai", "OpenAI(GPT-4o 视觉)", svcCatLLMVision, 0,
sf("api_key", "API Key", true), sf("model", "视觉模型,如 gpt-4o", false), sf("base_url", "接口地址(可选)", false)),
tpl("llmv_qwen", "qwen", "通义千问 VL(阿里)", svcCatLLMVision, 1,
sf("api_key", "API Key", true), sf("model", "视觉模型,如 qwen-vl-max", false), sf("base_url", "接口地址(可选)", false)),
tpl("llmv_gemini", "google", "Gemini(Google)", svcCatLLMVision, 2,
sf("api_key", "API Key", true), sf("model", "视觉模型,如 gemini-2.5-flash", false), sf("endpoint", "自定义 Endpoint(可选)", false)),
tpl("llmv_doubao", "doubao", "豆包视觉(火山引擎)", svcCatLLMVision, 3,
sf("api_key", "API Key", true), sf("endpoint_id", "推理接入点 ID", false), sf("base_url", "接口地址", false)),
// 中国移动 灵犀(客户端「移动精灵」模块,原生 agent_service 那条链路)。2026-09-14 从业务库 config 表
// 的 AGENT_TYPE / MOBILE_ELF_* / YIDONG_PID 搬来(migrate_appparams.go),字段值仍按老键名下发。
// 归到「文本大模型」只是分组:它不出音频给我们(音频在原生 SDK 内部闭环),放 STS 会被自动
// 加上 languages/default_lang 两个对它没意义的字段、还被巡检当成「没配语言」。
// agent_type 是 AI 球的跳转开关:值为 yidong 时进移动精灵,其它一律进 EMAI;
// 停用本服务 = 不下发这些键 = 客户端按默认进 EMAI。
tpl("llm_mobile_elf", "chinamobile", "中国移动 灵犀(移动精灵)", svcCatLLM, 9,
sfe("agent_type", "AI 球跳转:yidong=移动精灵,其它=EMAI 助手", false, "AGENT_TYPE", ""),
sfe("agent_id", "灵犀 AgentId", false, "MOBILE_ELF_AGENT_ID", ""),
sfe("product_id", "灵犀产品 ID", false, "MOBILE_ELF_PRODUCT_ID", ""),
sfe("product_key", "灵犀产品密钥", true, "MOBILE_ELF_PRODUCT_KEY", ""),
sfe("pid", "移动产品 PID(设备检测匹配用)", false, "YIDONG_PID", "")),
// 应用参数:非凭据的应用级业务参数。后台「应用参数」标签页 2026-09-14 下线后,这类东西的唯一入口。
// 每个字段带下发键名,客户端零改动。要加新参数:这里加一个 sfe,patchSvcConfigFields 会把它补到各应用的实例上。
tpl("app_params", "app", "应用参数", svcCatAppParams, 0,
sfe("iap_customer_service_qq", "内购页页脚显示的客服 QQ(留空不显示)", false, "iapCustomerServiceQQ", "")),
// STS 端到端语音对话
tpl("sts_volcengine", "volcengine", "火山引擎 STS", svcCatSTS, 0,
sf("appid", "应用 AppID", false), sf("token", "访问 Token", true), sf("cluster", "集群标识", false)),
tpl("sts_doubao", "doubao", "豆包(火山)STS", svcCatSTS, 1,
sf("appid", "应用 AppID", false), sf("token", "访问 Token", true), sf("cluster", "集群标识", false)),
tpl("sts_azure", "azure", "微软 Azure STS", svcCatSTS, 2,
sf("subscription_key", "订阅密钥", true), sf("region", "区域,如 eastus", false)),
tpl("sts_google", "google", "Google STS", svcCatSTS, 3,
sf("api_key", "API Key", true)),
// 科大讯飞 AIUI(aiui.xfyun.cn):唤醒→识别→语义理解→内容服务→合成 一条链路的人机交互平台,
// 归 STS(端到端语音对话)而不是 STT——它吐的是「听懂之后的答复」,不是转写文本。
//
// 鉴权是**自成一套**的,别照别家的 key/secret 套:请求头 X-Appid / X-CurTime / X-Param(业务参数的 base64)
// / X-CheckSum = MD5(api_key + X-CurTime + X-Param)。所以只有 app_id 与 api_key 两个是必填凭据,
// 其余都是 X-Param 里的业务参数。
//
// ⚠️ auth_id 是**每个用户/设备一个**的标识(32 位小写字母+数字),AIUI 拿它存个性化数据与多轮上下文。
// 这里留空是推荐做法(由端侧按设备 id 生成);填死等于让全部用户共用一份上下文,A 说的话会进 B 的对话历史。
// 留这个字段只是给「先用固定 id 联调」这种场景一个入口。
//
// 音频格式(sample_rate/aue)刻意不做成字段:端侧音频出口固定 PCM16/16k/mono,配成别的只会两边对不上。
// 发音人在 AIUI 控制台的场景里配,不从这边下发。
tpl("sts_iflytek_aiui", "iflytek", "科大讯飞 AIUI", svcCatSTS, 4,
sf("app_id", "AIUI 应用 ID(请求头 X-Appid)", false),
sf("api_key", "AIUI API Key(算 X-CheckSum 用)", true),
sfd("ws_url", "接口地址", false, "wss://wsapi.xfyun.cn/v1/aiui"),
sfd("scene", "场景,对应 AIUI 控制台里建的那个场景", false, "main"),
sfd("interact_mode", "交互模式:oneshot=一次问答(按键说话适用),continuous=持续交互", false, "oneshot"),
sf("auth_id", "用户标识(32位小写字母+数字)。留空=端侧按设备生成,推荐留空", false)),
// 存储 对象存储
// domain 可选:填了之后「上传完成后存进库的那个访问地址」走该域名(OSS 绑定的自定义域名 / CDN),
// 留空则回退 bucket 的 OSS 源站地址。注意它只影响【下载地址】——浏览器 PUT 用的预签名 URL
// 必须仍是 OSS 源站,签名是按源站 host 算的,换域名会被判签名不符。
tpl("storage_aliyun_oss", "aliyun_oss", "阿里云 OSS", svcCatStorage, 0,
sf("access_key_id", "AccessKey ID", true), sf("access_key_secret", "AccessKey Secret", true), sf("bucket", "存储桶", false), sf("endpoint", "地域节点 Endpoint", false),
sf("domain", "自定义域名(可选,如 cdn.ymaikj.com;留空用 OSS 源站地址)", false)),
tpl("storage_tencent_cos", "tencent_cos", "腾讯云 COS", svcCatStorage, 1,
sf("secret_id", "SecretId", true), sf("secret_key", "SecretKey", true), sf("bucket", "存储桶", false), sf("region", "地域,如 ap-guangzhou", false)),
// MCP 服务(模型上下文协议)。字段均非加密:url 服务地址,type 传输类型(0=HTTP/1=SSE),tools 启用工具白名单。
// 各区域的实际 url/tools 通过「区域覆盖分叉」维护,运行时按客户端区域解析。
tpl("mcp_default", "custom", "MCP 服务", svcCatMCP, 0,
sf("url", "服务地址,如 https://...", false), sfd("type", "传输类型:0=HTTP,1=SSE", false, "0"), sf("tools", "启用工具(逗号分隔,空=全部)", false)),
// 身份证实名核验(二要素:姓名 + 身份证号)。
//
// ⚠️ 这两家的凭据都是**云账号主 AK/SK**,拿到即可操作整个云账号。它们放在
// svc_config 只是为了统一后台管理入口——comm.IsServerOnlySvc 会在 resolveThirdSvcs
// 里把类别 11 整条拦掉,绝不随任何客户端接口下发。改动那处过滤前请先想清楚后果。
//
// 阿里云 Id2MetaVerify:域名 cloudauth.aliyuncs.com,Version 2019-03-07,
// 出参 ResultObject.BizCode(1=一致,2=不一致)。region/domain 留空走默认 cn-shanghai。
tpl("idverify_aliyun", "aliyun", "阿里云 身份二要素核验", svcCatIdVerify, 0,
sf("access_key_id", "AccessKey ID", true), sf("access_key_secret", "AccessKey Secret", true),
sfd("region", "地域(可选,默认 cn-shanghai)", false, "cn-shanghai"),
sfd("domain", "接口域名(可选)", false, "cloudauth.aliyuncs.com")),
// 腾讯云 IdCardVerification:域名 faceid.tencentcloudapi.com,Version 2018-03-01,
// 出参 Result("0"=一致,"-1"=不一致,-2..-7 为各类错误)。本接口不需要传 Region。
tpl("idverify_tencent", "tencent", "腾讯云 身份二要素核验", svcCatIdVerify, 1,
sf("secret_id", "SecretId", true), sf("secret_key", "SecretKey", true),
sf("region", "地域(本接口不需要,留空即可)", false)),
// 创蓝云智(253) OM2.0 身份证二要素:
// POST wsauth.253.com/api/v2/auth/idcard/id-card-auth,application/json,
// 鉴权走**请求头**(AppID/Nonce/CurTime/CheckSum,CheckSum=SHA1(AppSecret+Nonce+CurTime)),
// 出参 data.result(01=一致 02=不一致 03=认证不确定 04=认证失败,
// 后两者不计费且**不代表不一致**)。
// 字段键名沿用 appid/appkey(存量配置已在用),值分别是控制台
// 「账号中心 → API Key」里的 AppID 与 AppSecret。
// ⚠️ 别配成老版 /open/idcard/id-card-auth——拿 OM2.0 的 AppID 打老接口会回
// 500902「用户不存在」,看着像凭据错,实则是接口版本用错了。
// url 留空即走代码里的默认地址;服务商换地址时可在后台直接改,不用重新发版。
// 这里刻意不预填默认值——预填过的值 seed 不会再覆盖(见 appendMissingFields),
// 一旦地址变更,老部署的表单里会永远留着旧地址。
tpl("idverify_chuanglan", "chuanglan", "创蓝 身份二要素核验", svcCatIdVerify, 2,
sf("appid", "AppID(控制台→账号中心→API Key)", true),
sf("appkey", "AppSecret", true),
sf("url", "接口地址(可选,留空用默认)", false)),
}
// 前置语言字段(languages 存逗号分隔 BCP-47 如 zh-CN,en-US;default_lang 存单个码):
// - 音频输出类(TTS/AST/STS):支持语言 + 默认语言;
// - 录音文件识别(ASRFile):只需「支持语言」,用于会议记录选路过滤,无默认语言概念。
// 不绑定单一语言,音色/语速等仍由各服务商自有字段决定,运行时按请求语言选择。
for i := range list {
cat := list[i].Category
var langFields []SvcField
switch {
case audioOutputCats[cat]:
// languages 预填成「内置音色表里这家服务商有音色的语言」——有音色才说得出来,
// 让运营手抄一遍既费事又容易抄错。音色表没覆盖的服务商留空,仍由运营自己填。
langFields = []SvcField{
{Key: "languages", Description: "支持的语言(可多选)", Type: "langs", DefValue: strings.Join(comm.TTSLanguages(list[i].Provider), ",")},
{Key: "default_lang", Description: "默认语言", Type: "lang"},
}
case asrLangCats[cat]:
langFields = []SvcField{
{Key: "languages", Description: "支持识别的语言(可多选,选路按此过滤)", Type: "langs", DefValue: asrFileDefaultLangs[list[i].Provider]},
}
default:
continue
}
list[i].Fields = append(langFields, list[i].Fields...)
for j := range list[i].Fields {
list[i].Fields[j].Sort = int32(j)
}
}
return list
}
// deprecatedTemplateFields 内置模板里已废弃的字段:seed 时从库里的内置模板行删掉。
//
// seed 平时只补不删(避免踩掉运营自己加的字段),但「字段本身作废了」是另一回事——
// 不删的话它会一直挂在新建服务的表单上,让人以为还得填。
// 只删这里明确列出的 key。作用范围:builtin 模板行**全删**;已创建的服务实例(svc_config)
// **只删空着的那些**(见 removeDeprecatedEmptyFields)——运营往废弃字段里填过值时一律保留,
// 那是数据,删了不可逆。
//
// ⚠️ 早先这里写的是「实例一律不动,多一个用不到的字段无害」。实测不成立:空的**加密**字段
// 会被 svcCredMissing 判成缺凭据、被 checkSvcRequiredFields 判成不许保存,
// 于是那个服务在后台既显示告警又改不动。
//
// tts_azure:音色/语速/音调不该配在服务上——一个 TTS 服务要服务多个 agent、多种语言,
// 音色得按 agent 的「语言+性别」从音色表里选(见 comm/ttsvoice.go),配在服务上只会跟 agent 打架。
// ast_alibaba:AccessKey ID/Secret 是云账号主密钥,这条链路(百炼 qwen3.5-livetranslate)
// 根本不用它们,凭据是 app_key。留着的实际危害不只是多两行——它们是**加密字段**,
// 空着会让 svcCredMissing 给服务打上「⚠ 缺凭据」,还会让 checkSvcRequiredFields
// 直接拒绝保存("凭据字段不能为空…请先停用再保存"),运营根本改不动这个服务。
var deprecatedTemplateFields = map[string][]string{
"tts_azure": {"voice_name", "rate", "pitch"},
"ast_alibaba": {"access_key_id", "access_key_secret"},
}
// removeDeprecatedFields 从字段列表里剔除该模板已废弃的 key,返回新列表与被删的 key。
func removeDeprecatedFields(tplId string, fields []SvcField) ([]SvcField, []string) {
drop := deprecatedTemplateFields[tplId]
if len(drop) == 0 {
return fields, nil
}
dropSet := make(map[string]bool, len(drop))
for _, k := range drop {
dropSet[k] = true
}
out := make([]SvcField, 0, len(fields))
removed := make([]string, 0, len(drop))
for _, f := range fields {
if dropSet[f.Key] {
removed = append(removed, f.Key)
continue
}
out = append(out, f)
}
return out, removed
}
// removeDeprecatedEmptyFields 从**服务实例**的字段列表里剔除该模板已废弃、且当前为空的 key。
// 有值的废弃字段保留(那是运营填过的数据)。返回新列表与被删的 key。
func removeDeprecatedEmptyFields(tplId string, fields []SvcField) ([]SvcField, []string) {
drop := deprecatedTemplateFields[tplId]
if len(drop) == 0 {
return fields, nil
}
dropSet := make(map[string]bool, len(drop))
for _, k := range drop {
dropSet[k] = true
}
out := make([]SvcField, 0, len(fields))
removed := make([]string, 0, len(drop))
for _, f := range fields {
if dropSet[f.Key] && strings.TrimSpace(f.DefValue) == "" {
removed = append(removed, f.Key)
continue
}
out = append(out, f)
}
return out, removed
}
// seedSvcTemplates 幂等 seed 内置模板:同 id 不存在则整条插入;已存在则只**补齐新增字段**,
// 不覆盖运营改过的既有字段(描述/默认值/顺序一律保留)。
// 在 ensureConfigTables 之后调用(启动一次)。
//
// 补字段这步是必需的:内置模板加新字段(如阿里的 workspace_id)时,老部署的 DB 里已经有同 id 记录,
// 若直接 continue 跳过,新字段永远不会出现在后台表单里,运营也就无从配置。
func seedSvcTemplates() {
now := time.Now().UnixMilli()
inserted, patched := 0, 0
for _, t := range builtinSvcTemplates() {
exist := &ThirdSvcTemplate{}
if err := postgres.FindOne(comm.TableThirdSvcTemplate, exist, "id=?", t.Id); err == nil {
fields, removed := removeDeprecatedFields(t.Id, exist.Fields)
fields, added := appendMissingFields(fields, t.Fields)
if len(added) == 0 && len(removed) == 0 {
continue
}
if len(removed) > 0 {
log.Info("console: 内置服务商模板已移除废弃字段", log.Field{Key: "id", Value: t.Id}, log.Field{Key: "fields", Value: removed})
}
exist.Fields = fields
exist.Updatetime = now
if err := postgres.Save(comm.TableThirdSvcTemplate, exist); err != nil {
log.Warn("console: 内置服务商模板补字段失败", log.Field{Key: "id", Value: t.Id}, log.Field{Key: "err", Value: err.Error()})
continue
}
patched++
log.Info("console: 内置服务商模板已补字段", log.Field{Key: "id", Value: t.Id}, log.Field{Key: "fields", Value: added})
continue
}
t.Createtime = now
t.Updatetime = now
if err := postgres.Insert(comm.TableThirdSvcTemplate, &t); err != nil {
log.Warn("console: 内置服务商模板 seed 失败", log.Field{Key: "id", Value: t.Id}, log.Field{Key: "err", Value: err.Error()})
continue
}
inserted++
}
if inserted > 0 || patched > 0 {
log.Info("console: 内置服务商模板 seed 完成",
log.Field{Key: "inserted", Value: inserted}, log.Field{Key: "patched", Value: patched})
}
}
// appendMissingFields 把 want 里 have 尚未拥有的字段(按 Key 判重)追加到末尾,
// 返回补全后的切片和新追加的 key 列表。已有字段一概不动——运营可能改过描述或默认值。
// 追加时保留模板的 DefValue:对 languages 这类有语义的字段,留空(=不限)比给默认值危险得多。
func appendMissingFields(have []SvcField, want []SvcField) (out []SvcField, added []string) {
has := make(map[string]bool, len(have))
for _, f := range have {
has[f.Key] = true
}
out = have
for _, f := range want {
if has[f.Key] {
continue
}
f.Sort = int32(len(out))
out = append(out, f)
added = append(added, f.Key)
}
return
}
// aliasFields 历史遗留字段名 → 现行字段名。
// 早期后台手工加过 callback 字段存回调地址,但运行时从来只读 callback_path
// (见 modules/echomeet/orch_resolver.go),于是老实例里两个字段并存、旧的那个是死值。
// 迁移策略:值搬到新 key(新 key 已有值则以新的为准),旧 key 删除。
var aliasFields = map[string]string{
"callback": "callback_path",
}
// migrateAliasFields 把实例里的遗留字段并入现行字段,返回迁移掉的旧 key(空=无改动)。
// 纯函数不打日志,由调用方负责——否则单测要先初始化整套 log 子系统。
// 幂等:迁完旧 key 就没了,后续启动是 no-op。
func migrateAliasFields(svc *ThirdSvcConfig) (migrated []string) {
kept := make([]SvcField, 0, len(svc.Fields))
for _, f := range svc.Fields {
if _, isAlias := aliasFields[f.Key]; !isAlias {
kept = append(kept, f)
}
}
// 旧值逐个并入:新字段已有值时以新值为准,绝不用旧值覆盖运营配好的内容
for _, f := range svc.Fields {
newKey, isAlias := aliasFields[f.Key]
if !isAlias {
continue
}
migrated = append(migrated, f.Key)
val := normalizeCallbackPath(f.DefValue)
if val == "" { // 旧字段本来就没值,直接丢弃;新字段交给模板补齐(带正确默认值)
continue
}
if j := indexOfField(kept, newKey); j >= 0 {
if strings.TrimSpace(kept[j].DefValue) == "" {
kept[j].DefValue = val
}
continue
}
nf := f
nf.Key = newKey
nf.DefValue = val
kept = append(kept, nf)
}
if len(migrated) == 0 {
return nil
}
for i := range kept {
kept[i].Sort = int32(i)
}
svc.Fields = kept
return
}
// indexOfField 按 key 找字段下标,没有返回 -1
func indexOfField(fields []SvcField, key string) int {
for i, f := range fields {
if f.Key == key {
return i
}
}
return -1
}
// normalizeCallbackPath 回调存的是相对路径,运行时直接和 base_url 拼接,缺前导斜杠会拼出错误地址
func normalizeCallbackPath(p string) string {
if p = strings.TrimSpace(p); p != "" && !strings.HasPrefix(p, "/") {
return "/" + p
}
return p
}
// patchSvcConfigFields 把内置模板新增的字段补到**已建好的服务实例**上(表 TableSvcConfig)。
//
// 必需的一步:模板(svc_template)和服务实例(svc_config)是两张独立的表,实例的 Fields 是
// 创建那一刻从模板拷贝的快照。后续给模板加字段(如阿里的 workspace_id)不会传导过去,
// 于是后台「第三方服务配置」里那条早就建好的阿里服务永远看不到新字段,运营也就填不了。
//
// 匹配规则:provider 相同 且 categories 含该模板的 category。只追加缺失的 key,
// 不改任何已有字段的值——凭据和运营的配置一律不碰。
func patchSvcConfigFields() {
svcs := make([]*ThirdSvcConfig, 0)
if err := postgres.Find(comm.TableSvcConfig, &svcs, ""); err != nil {
log.Warnf("console: 读取服务配置失败,跳过字段补齐: %v", err)
return
}
tpls := builtinSvcTemplates()
now := time.Now().UnixMilli()
patched := 0
for _, svc := range svcs {
// 先并掉遗留字段,再补缺失字段——否则会先补出一个空的新字段,旧值就搬不过去了
migrated := migrateAliasFields(svc)
if len(migrated) > 0 {
log.Info("console: 服务配置已并入遗留字段",
log.Field{Key: "app", Value: svc.AppName}, log.Field{Key: "svc", Value: svc.Id},
log.Field{Key: "removed", Value: migrated})
}
var added []string
for i := range tpls {
t := &tpls[i]
if t.Provider != svc.Provider || !categoriesHas(svc.Categories, t.Category) {
continue
}
// 先清掉该模板已废弃、且实例上还空着的字段,再补缺失字段。
var dropped []string
svc.Fields, dropped = removeDeprecatedEmptyFields(t.Id, svc.Fields)
if len(dropped) > 0 {
migrated = append(migrated, dropped...)
}
var a []string
svc.Fields, a = appendMissingFields(svc.Fields, t.Fields)
added = append(added, a...)
}
if len(added) == 0 && len(migrated) == 0 {
continue
}
svc.Updatetime = now
if err := postgres.Save(comm.TableSvcConfig, svc); err != nil {
log.Warn("console: 服务配置补字段失败",
log.Field{Key: "app", Value: svc.AppName}, log.Field{Key: "svc", Value: svc.Id}, log.Field{Key: "err", Value: err.Error()})
continue
}
patched++
log.Info("console: 服务配置已补字段",
log.Field{Key: "app", Value: svc.AppName}, log.Field{Key: "svc", Value: svc.Id}, log.Field{Key: "fields", Value: added})
}
if patched > 0 {
log.Info("console: 服务配置字段补齐完成", log.Field{Key: "patched", Value: patched})
}
}
// categoriesHas 判断逗号分隔的 categories 串是否含指定类别
func categoriesHas(categories string, cat int32) bool {
want := strconv.Itoa(int(cat))
for _, s := range strings.Split(categories, ",") {
if strings.TrimSpace(s) == want {
return true
}
}
return false
}