Browse Source
后台 - 服务配置去掉「全局默认」层,svc_config / svc_region_override / echomeet_orch / echomeet_orch_setting / echomeet_template 五张表统一按 app_name 隔离, 启动迁移 migrate_scope.go 幂等分配(首个应用原地改名保 id,其余拷贝); 写接口 app_name 必填(scope_check.go)。 - 类别落库 svc_category,可增删改;内置 11 个 id 不可改不可删,运行时语义仍只挂 comm.SvcCat*。 - 「应用参数」标签页下线:业务库 config 表不再随 user_getappconfig 下发, migrate_appparams.go 把 AGENT_TYPE / MOBILE_ELF_* 迁到服务 llm_mobile_elf、 iapCustomerServiceQQ 迁到 app_params,COMPUTE_*/NEWUSER_GIFT_* 留在 config 表不下发, 无人读的音乐/公码/导航/COS_*/MeetServers 删除,其余键保留并在启动日志告警。 - 删除 console appcfg/* 与 api 模块的 get/add/update/delconfig、唤醒音四个接口(无调用方)。 - 修 getmcpservers 仍查全局层导致 MCP 类服务永远读不到。 客户端 - 凭据改走结构化通道:user_getappconfig_v3 整体 AES-CBC 加密(api_crypto.dart, 拦截器须排在 AuthInterceptor 之前),AppConfig.cred(svcId, field, envKey) 取值, 老键名只作兜底;lib 内 AppConfig.env( 已降到 0 处调用。 - 图片翻译的 ALIBABA_VL_MODEL / ENDPOINT 改挂 ast_alibaba。 启动日志 - 删除 sys/auth/firebase(google_auth 的重复实现,零调用方,且同一错误刷两行)。 - api 模块不再每次启动无条件插入默认超管(Duplicate entry 'admin')。 - sys/auth/* 五个包的 logger 名从复制来的 sys.tavily 各改各名; google_auth 启动时即判断配置是否可用。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>main
86 changed files with 2414 additions and 1196 deletions
@ -1,264 +0,0 @@ |
|||||
<template> |
|
||||
<div> |
|
||||
<div class="rounded-lg px-3 py-2 mb-4 text-xs" style="background:#f8fafc;border:1px solid #e2e8f0;color:#475569"> |
|
||||
这里只放<b>应用自己的业务参数</b>(导航方式、客服 QQ、智能体标识…),直接读写所选部署的业务库 config 表。 |
|
||||
第三方服务的<b>凭据请到「第三方服务」标签页配</b>——2026-09-12 起凭据统一收在那里(带加密、区域分叉与巡检), |
|
||||
下发给客户端时按字段上的「下发键名」自动回填一份 env,老客户端无需升级。 |
|
||||
</div> |
|
||||
<div class="flex items-center justify-between mb-4 flex-wrap gap-3"> |
|
||||
<div class="flex items-center gap-3 flex-wrap"> |
|
||||
<span class="text-sm text-slate-500">应用</span> |
|
||||
<select v-model="curAppName" class="select select-bordered select-sm min-w-40" @change="onAppNameChange"> |
|
||||
<option value="" disabled>请选择应用</option> |
|
||||
<option v-for="n in appNames" :key="n" :value="n">{{ n }}</option> |
|
||||
</select> |
|
||||
<span class="text-sm text-slate-500">部署环境</span> |
|
||||
<select |
|
||||
v-model.number="curApp" |
|
||||
class="select select-bordered select-sm min-w-48" |
|
||||
:disabled="!curAppName" |
|
||||
@change="loadData" |
|
||||
> |
|
||||
<option :value="0" disabled>请选择环境</option> |
|
||||
<option v-for="d in appDeployments" :key="d.id" :value="d.id">{{ deployLabel(d) }}</option> |
|
||||
</select> |
|
||||
<input v-model="keyword" class="input input-bordered input-sm w-48" placeholder="按分组/键搜索" /> |
|
||||
</div> |
|
||||
<button class="btn btn-primary btn-sm text-white" :disabled="!curApp" @click="openForm()">+ 新增参数</button> |
|
||||
</div> |
|
||||
|
|
||||
<div v-if="!curApp" class="bg-white rounded-xl shadow-sm border border-slate-100 text-center text-slate-400 py-16"> |
|
||||
<div class="text-4xl mb-2">🗂️</div> |
|
||||
<p class="text-sm">{{ curAppName ? '该应用还没有部署环境,请先在「应用中心」为它部署一个环境' : '请先在上方选择应用和部署环境' }}</p> |
|
||||
</div> |
|
||||
<div v-else class="bg-white rounded-xl shadow-sm border border-slate-100"> |
|
||||
<div class="overflow-x-auto"> |
|
||||
<table class="table"> |
|
||||
<thead> |
|
||||
<tr><th>键</th><th>值</th><th>描述</th><th class="text-right">操作</th></tr> |
|
||||
</thead> |
|
||||
<tbody> |
|
||||
<tr v-if="loading"><td colspan="4" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td></tr> |
|
||||
<tr v-else-if="!filteredRows.length"><td colspan="4" class="text-center text-slate-400 py-8">暂无参数</td></tr> |
|
||||
<template v-for="grp in groupedRows" :key="grp.key"> |
|
||||
<tr class="bg-slate-100/80"> |
|
||||
<td colspan="4" class="py-2"> |
|
||||
<span class="font-semibold text-slate-600 text-sm">▸ {{ grp.label }}</span> |
|
||||
<span class="text-xs text-slate-400 ml-2">{{ grp.items.length }} 项</span> |
|
||||
</td> |
|
||||
</tr> |
|
||||
<tr v-for="c in grp.items" :key="c.id" class="hover"> |
|
||||
<td class="font-mono text-sm font-medium">{{ c.key || '' }}</td> |
|
||||
<td class="text-sm text-slate-500 max-w-xs truncate" :title="c.value || ''">{{ c.value || '' }}</td> |
|
||||
<td class="text-sm text-slate-400">{{ c.description || '' }}</td> |
|
||||
<td class="text-right"> |
|
||||
<div class="inline-flex gap-1"> |
|
||||
<button class="btn btn-ghost btn-xs" @click="openForm(c)">编辑</button> |
|
||||
<button class="btn btn-ghost btn-xs text-red-500" @click="delCfg(c)">删除</button> |
|
||||
</div> |
|
||||
</td> |
|
||||
</tr> |
|
||||
</template> |
|
||||
</tbody> |
|
||||
</table> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<!-- 编辑弹窗:留在同一个标签页里改,不跳走第二屏 --> |
|
||||
<div v-if="formOpen" class="modal modal-open"> |
|
||||
<div class="modal-box max-w-xl"> |
|
||||
<h3 class="font-bold text-base mb-1">{{ form.id ? '编辑参数' : '新增参数' }}</h3> |
|
||||
<p class="text-xs text-slate-400 mb-4">配置目标:{{ curAppLabel }}</p> |
|
||||
<div class="space-y-3"> |
|
||||
<div> |
|
||||
<label class="text-xs text-slate-500">分组 (group)</label> |
|
||||
<input v-model="form.group" class="input input-bordered input-sm w-full" placeholder="如 APP / AI / other" /> |
|
||||
</div> |
|
||||
<div> |
|
||||
<label class="text-xs text-slate-500">键 (key) *</label> |
|
||||
<input v-model="form.key" class="input input-bordered input-sm w-full font-mono" placeholder="如 APP_NAVIGATION_MODE" /> |
|
||||
</div> |
|
||||
<div> |
|
||||
<label class="text-xs text-slate-500">值 (value)</label> |
|
||||
<textarea v-model="form.value" class="textarea textarea-bordered w-full font-mono text-sm" rows="3"></textarea> |
|
||||
</div> |
|
||||
<div> |
|
||||
<label class="text-xs text-slate-500">描述</label> |
|
||||
<input v-model="form.description" class="input input-bordered input-sm w-full" /> |
|
||||
</div> |
|
||||
</div> |
|
||||
<div class="modal-action"> |
|
||||
<button class="btn btn-ghost btn-sm" @click="formOpen = false">取消</button> |
|
||||
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="saveCfg">保存</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</template> |
|
||||
|
|
||||
<script setup lang="ts"> |
|
||||
import { ref, reactive, computed, onMounted } from 'vue' |
|
||||
import { useApi } from '~/composables/useApi' |
|
||||
import { useToast } from '~/composables/useToast' |
|
||||
import { regionLabel } from '~/utils/regions' |
|
||||
|
|
||||
// 「应用环境配置」原来是独立页面(pages/appconfig.vue),2026-09-12 并进「服务与环境配置」的第二个标签页。 |
|
||||
// 合并的理由:凭据已统一收进 svc_config,这张表剩下的只是应用自己的业务参数, |
|
||||
// 单独占一个一级菜单项与「第三方服务配置」并列,只会让人分不清凭据该填哪儿。 |
|
||||
|
|
||||
// 部署应用(与应用中心 apps/list 同源;id 为部署注册 id,用于连接该应用业务库)。 |
|
||||
// 同一个应用会在多个环境各有一条部署记录,靠 env_id 区分——参数按「应用 × 环境」独立。 |
|
||||
interface Deployment { id: number; app_name: string; status: string; name: string; region: string; env_id: number } |
|
||||
interface EnvOption { id: number; name: string; code: string; enabled: boolean } |
|
||||
interface AppConfig { id: number; group: string; key: string; value: string; description: string } |
|
||||
|
|
||||
const { consoleApi } = useApi() |
|
||||
const { success, error } = useToast() |
|
||||
|
|
||||
const apps = ref<Deployment[]>([]) |
|
||||
const envs = ref<EnvOption[]>([]) |
|
||||
const curAppName = ref('') |
|
||||
const curApp = ref(0) |
|
||||
const items = ref<AppConfig[]>([]) |
|
||||
const loading = ref(false) |
|
||||
const submitting = ref(false) |
|
||||
const keyword = ref('') |
|
||||
const formOpen = ref(false) |
|
||||
const form = reactive({ id: 0, group: '', key: '', value: '', description: '' }) |
|
||||
|
|
||||
const appNames = computed(() => |
|
||||
[...new Set(apps.value.map((a) => a.app_name || a.name).filter(Boolean))].sort() |
|
||||
) |
|
||||
const appDeployments = computed(() => |
|
||||
apps.value.filter((a) => (a.app_name || a.name) === curAppName.value) |
|
||||
) |
|
||||
|
|
||||
// 部署对应的环境名;区域不同也一并标出,避免同环境多区域时分不清 |
|
||||
function deployLabel(d: Deployment) { |
|
||||
const e = envs.value.find((x) => x.id === d.env_id) |
|
||||
const envName = e ? (e.name || e.code) : d.env_id ? `环境#${d.env_id}` : '未关联环境' |
|
||||
const suffix = d.status === 'pending' ? '(待部署)' : '' |
|
||||
return d.region ? `${envName} · ${regionLabel(d.region)}${suffix}` : `${envName}${suffix}` |
|
||||
} |
|
||||
|
|
||||
const curAppLabel = computed(() => { |
|
||||
const a = apps.value.find((x) => x.id === curApp.value) |
|
||||
return a ? `${a.app_name || a.name} · ${deployLabel(a)}` : '' |
|
||||
}) |
|
||||
|
|
||||
const filteredRows = computed(() => { |
|
||||
const kw = keyword.value.trim().toLowerCase() |
|
||||
if (!kw) return items.value |
|
||||
return items.value.filter((c) => ((c.group || '') + (c.key || '')).toLowerCase().includes(kw)) |
|
||||
}) |
|
||||
|
|
||||
// 按 group 分组:相同组归在一起(未分组排最前) |
|
||||
const groupedRows = computed(() => { |
|
||||
const groups: Record<string, AppConfig[]> = {} |
|
||||
filteredRows.value.forEach((c) => { |
|
||||
const g = c.group || '' |
|
||||
;(groups[g] = groups[g] || []).push(c) |
|
||||
}) |
|
||||
const names = Object.keys(groups).sort((a, b) => { |
|
||||
if (a === '') return -1 |
|
||||
if (b === '') return 1 |
|
||||
return a < b ? -1 : 1 |
|
||||
}) |
|
||||
return names.map((g) => ({ key: g, label: g || '未分组', items: groups[g] })) |
|
||||
}) |
|
||||
|
|
||||
async function loadApps() { |
|
||||
try { |
|
||||
const [list, es] = await Promise.all([ |
|
||||
consoleApi<Deployment[]>('apps/list', {}), |
|
||||
consoleApi<EnvOption[]>('envs/list', {}), |
|
||||
]) |
|
||||
apps.value = list ?? [] |
|
||||
envs.value = es ?? [] |
|
||||
if (!curAppName.value && appNames.value.length) { |
|
||||
curAppName.value = appNames.value[0]! |
|
||||
pickDefaultDeployment() |
|
||||
await loadData() |
|
||||
} |
|
||||
} catch (e: any) { |
|
||||
error(e?.message ?? '获取应用列表失败') |
|
||||
apps.value = [] |
|
||||
envs.value = [] |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 在当前应用下挑一个默认部署环境;优先已接入的(待部署的业务库通常还连不上) |
|
||||
function pickDefaultDeployment() { |
|
||||
const list = appDeployments.value |
|
||||
const first = list.find((d) => d.status !== 'pending') ?? list[0] |
|
||||
curApp.value = first?.id ?? 0 |
|
||||
} |
|
||||
|
|
||||
function onAppNameChange() { |
|
||||
pickDefaultDeployment() |
|
||||
loadData() |
|
||||
} |
|
||||
|
|
||||
async function loadData() { |
|
||||
if (!curApp.value) { |
|
||||
items.value = [] |
|
||||
return |
|
||||
} |
|
||||
loading.value = true |
|
||||
try { |
|
||||
const data = await consoleApi<{ items: AppConfig[] }>('appcfg/list', { app_id: curApp.value }) |
|
||||
items.value = data?.items ?? [] |
|
||||
} catch (e: any) { |
|
||||
error(e?.message ?? '获取参数失败') |
|
||||
items.value = [] |
|
||||
} finally { |
|
||||
loading.value = false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
function openForm(cfg?: AppConfig) { |
|
||||
form.id = cfg?.id ?? 0 |
|
||||
form.group = cfg?.group ?? '' |
|
||||
form.key = cfg?.key ?? '' |
|
||||
form.value = cfg?.value ?? '' |
|
||||
form.description = cfg?.description ?? '' |
|
||||
formOpen.value = true |
|
||||
} |
|
||||
|
|
||||
async function saveCfg() { |
|
||||
if (!curApp.value) { error('请先选择应用和部署环境'); return } |
|
||||
if (!form.key.trim()) { error('键(key) 必填'); return } |
|
||||
submitting.value = true |
|
||||
try { |
|
||||
const data: Record<string, unknown> = { |
|
||||
app_id: curApp.value, |
|
||||
group: form.group.trim(), |
|
||||
key: form.key.trim(), |
|
||||
value: form.value, |
|
||||
description: form.description.trim(), |
|
||||
} |
|
||||
if (form.id) { |
|
||||
data.id = Number(form.id) |
|
||||
await consoleApi('appcfg/update', data) |
|
||||
} else { |
|
||||
await consoleApi('appcfg/add', data) |
|
||||
} |
|
||||
success('保存成功') |
|
||||
formOpen.value = false |
|
||||
loadData() |
|
||||
} catch (e: any) { |
|
||||
error(e?.message ?? '保存失败') |
|
||||
} finally { |
|
||||
submitting.value = false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
function delCfg(c: AppConfig) { |
|
||||
if (!window.confirm(`确定删除参数 ${c.key} 吗?`)) return |
|
||||
consoleApi('appcfg/del', { app_id: curApp.value, ids: [c.id] }) |
|
||||
.then(() => { success('删除成功'); loadData() }) |
|
||||
.catch((e: any) => error(e?.message ?? '删除失败')) |
|
||||
} |
|
||||
|
|
||||
onMounted(loadApps) |
|
||||
</script> |
|
||||
@ -0,0 +1,86 @@ |
|||||
|
import 'dart:convert'; |
||||
|
|
||||
|
import 'package:dio/dio.dart'; |
||||
|
import 'package:encrypt/encrypt.dart' as enc; |
||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
||||
|
|
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
|
||||
|
/// 网关「加密接口」的响应解密。 |
||||
|
/// |
||||
|
/// 服务端(modules/gateway/wservice_comp.go `writeReply`)对声明在 `EncryptMsgs` 里的接口 |
||||
|
/// (`user_getappconfig_v2/v3`、`user_getthirdsvcs_v2`、`user_getagents_v2/v3`)把**整个响应体** |
||||
|
/// 做 AES-CBC 加密:key = 网关的 `GATEWAY_ENCRYPT_KEY`(16/24/32 字节),IV 固定 16 个 `'0'`, |
||||
|
/// PKCS5/7 填充,密文 base64;`Content-Type` 改成 `text/plain`,并加响应头 `X-Encrypted: 1`。 |
||||
|
/// 这些响应带着第三方服务的**明文凭据**,所以整体加密后才出网关。 |
||||
|
/// |
||||
|
/// 密钥来自客户端 `.env` 的 `GATEWAY_ENCRYPT_KEY`,须与服务器一致。没配时 [enabled] 为 false, |
||||
|
/// `Api.getappconfig` 会回退到明文 v1——凭据只能走 env 兼容层,`thirdsvcs` 拿不到。 |
||||
|
class ApiCrypto { |
||||
|
ApiCrypto._(); |
||||
|
|
||||
|
static const String _iv = '0000000000000000'; |
||||
|
|
||||
|
static String get _key => (dotenv.env['GATEWAY_ENCRYPT_KEY'] ?? '').trim(); |
||||
|
|
||||
|
/// 密钥是否配置且长度合法(AES 只接受 16/24/32 字节)。 |
||||
|
static bool get enabled { |
||||
|
final k = _key; |
||||
|
return k.length == 16 || k.length == 24 || k.length == 32; |
||||
|
} |
||||
|
|
||||
|
/// 解 base64(AES-CBC-PKCS7) 密文为明文字符串。密钥不合法或密文损坏时抛异常,由调用方决定退路。 |
||||
|
static String decrypt(String cipherBase64) { |
||||
|
final key = enc.Key.fromUtf8(_key); |
||||
|
final iv = enc.IV.fromUtf8(_iv); |
||||
|
final aes = enc.Encrypter(enc.AES(key, mode: enc.AESMode.cbc, padding: 'PKCS7')); |
||||
|
return aes.decrypt64(cipherBase64.trim(), iv: iv); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// Dio 拦截器:把 `X-Encrypted: 1` 的响应体解密并 JSON 解析,再交给后面的 [AuthInterceptor] |
||||
|
/// 按业务 JSON 处理。**必须排在 AuthInterceptor 之前**——它一看到不是 JSON 的 body 就当解析失败了。 |
||||
|
/// |
||||
|
/// 密文的 Content-Type 是 text/plain,Dio 的 `ResponseType.json` 不会去解析它,这里拿到的就是 String。 |
||||
|
class ApiDecryptInterceptor extends Interceptor { |
||||
|
static const String _tag = 'ApiCrypto'; |
||||
|
|
||||
|
@override |
||||
|
void onResponse(Response response, ResponseInterceptorHandler handler) { |
||||
|
final flag = response.headers.value('x-encrypted'); |
||||
|
if (flag != '1') { |
||||
|
handler.next(response); |
||||
|
return; |
||||
|
} |
||||
|
final body = response.data; |
||||
|
if (body is! String || body.isEmpty) { |
||||
|
handler.next(response); |
||||
|
return; |
||||
|
} |
||||
|
if (!ApiCrypto.enabled) { |
||||
|
// 服务端加密了、客户端却没配密钥:这不是网络问题,得让人一眼看到。 |
||||
|
Logger.e(_tag, '响应已加密但 .env 未配置 GATEWAY_ENCRYPT_KEY:${response.requestOptions.path}'); |
||||
|
handler.reject(DioException( |
||||
|
requestOptions: response.requestOptions, |
||||
|
response: response, |
||||
|
type: DioExceptionType.badResponse, |
||||
|
error: '响应已加密但客户端未配置 GATEWAY_ENCRYPT_KEY', |
||||
|
)); |
||||
|
return; |
||||
|
} |
||||
|
try { |
||||
|
final plain = ApiCrypto.decrypt(body); |
||||
|
response.data = jsonDecode(plain); |
||||
|
handler.next(response); |
||||
|
} catch (e) { |
||||
|
// 密钥不一致时 AES 解出来是乱码、PKCS7 去填充就会失败——报错信息要指向密钥,别让人去查网络。 |
||||
|
Logger.e(_tag, '解密失败(多半是 GATEWAY_ENCRYPT_KEY 与服务器不一致):${response.requestOptions.path} $e'); |
||||
|
handler.reject(DioException( |
||||
|
requestOptions: response.requestOptions, |
||||
|
response: response, |
||||
|
type: DioExceptionType.badResponse, |
||||
|
error: '响应解密失败,请检查 GATEWAY_ENCRYPT_KEY 是否与服务器一致', |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,35 @@ |
|||||
|
import 'dart:convert'; |
||||
|
|
||||
|
import 'package:eaimar/data/services/network/api_crypto.dart'; |
||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
||||
|
import 'package:flutter_test/flutter_test.dart'; |
||||
|
|
||||
|
/// 网关加密协议的兼容性金测试。 |
||||
|
/// 密文由 openssl 按服务端口径生成(lego/utils/crypto/aes/cbc.go:AES-CBC,IV 固定 16 个 '0', |
||||
|
/// PKCS5/7 填充,base64),不是用同一套 Dart 代码加密再解密——那只能证明自洽,证明不了与 Go 端一致。 |
||||
|
void main() { |
||||
|
const key = '0123456789abcdef'; |
||||
|
const cipher = '+DbMGxdFZ8VkBMivLb5FM7VgvUF+/ISnA8RRO6Nj3kyGbD3hV9fzAvC+DsTUoLiFVnHETIUGxCROudDtF4VyRJlAQQEMxpCf9Zx+19LMlFSPtWizfg+7SaJAJLZeY/lmZbCchmmMUkmFLmlyQMpaXw=='; |
||||
|
const plain = '{"code":0,"msg":"ok","data":{"env":{"A":"1"},"thirdsvcs":[{"id":"stt_azure","fields":{"region":"eastasia"}}]}}'; |
||||
|
|
||||
|
test('能解开 Go 端口径生成的密文', () { |
||||
|
dotenv.testLoad(fileInput: 'GATEWAY_ENCRYPT_KEY=$key'); |
||||
|
expect(ApiCrypto.enabled, isTrue); |
||||
|
final out = ApiCrypto.decrypt(cipher); |
||||
|
expect(out, plain); |
||||
|
final j = jsonDecode(out) as Map<String, dynamic>; |
||||
|
expect((j['data'] as Map)['thirdsvcs'], isA<List>()); |
||||
|
}); |
||||
|
|
||||
|
test('密钥长度不是 16/24/32 → enabled 为 false,走明文 v1', () { |
||||
|
dotenv.testLoad(fileInput: 'GATEWAY_ENCRYPT_KEY=short'); |
||||
|
expect(ApiCrypto.enabled, isFalse); |
||||
|
dotenv.testLoad(fileInput: 'GATEWAY_ENCRYPT_KEY='); |
||||
|
expect(ApiCrypto.enabled, isFalse); |
||||
|
}); |
||||
|
|
||||
|
test('密钥不一致 → 解密抛错(去 PKCS7 填充失败),不会静默给出乱码', () { |
||||
|
dotenv.testLoad(fileInput: 'GATEWAY_ENCRYPT_KEY=fedcba9876543210'); |
||||
|
expect(() => ApiCrypto.decrypt(cipher), throwsA(anything)); |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,44 @@ |
|||||
|
import 'package:eaimar/data/models/appconfig.dart'; |
||||
|
import 'package:eaimar/data/models/appconfig_model.dart'; |
||||
|
import 'package:flutter_test/flutter_test.dart'; |
||||
|
|
||||
|
/// AppConfig.cred 的取值口径:thirdsvcs 的 (服务 id, 字段) 优先,退回 env[老键名],两边都没有 → null。 |
||||
|
/// 各业务服务 2026-09-14 起都改走它;口径错了表现为「某个能力鉴权失败」,不报配置错误。 |
||||
|
void main() { |
||||
|
UserGetAppConfigResp build({Map<String, String> env = const {}, List<ThirdSvc> svcs = const []}) => |
||||
|
UserGetAppConfigResp(env: env, agents: const [], mcps: const {}, products: const [], thirdsvcs: svcs); |
||||
|
|
||||
|
test('thirdsvcs 有值时优先于 env', () { |
||||
|
AppConfig.initialize(build( |
||||
|
env: {'AZURE_SPEECH_KEY': 'from-env'}, |
||||
|
svcs: [ThirdSvc(id: 'stt_azure', name: '', provider: 'azure', categories: '1', fields: {'subscription_key': 'from-svc'})], |
||||
|
)); |
||||
|
expect(AppConfig.cred('stt_azure', 'subscription_key', 'AZURE_SPEECH_KEY'), 'from-svc'); |
||||
|
expect(AppConfig.usingThirdSvcs, isTrue); |
||||
|
}); |
||||
|
|
||||
|
test('thirdsvcs 没有该服务/字段或为空串 → 退回 env', () { |
||||
|
AppConfig.initialize(build( |
||||
|
env: {'AZURE_SPEECH_KEY': 'from-env'}, |
||||
|
svcs: [ThirdSvc(id: 'stt_azure', name: '', provider: 'azure', categories: '1', fields: {'subscription_key': ' '})], |
||||
|
)); |
||||
|
expect(AppConfig.cred('stt_azure', 'subscription_key', 'AZURE_SPEECH_KEY'), 'from-env'); |
||||
|
expect(AppConfig.cred('mt_azure', 'subscription_key', 'AZURE_SPEECH_KEY'), 'from-env'); |
||||
|
}); |
||||
|
|
||||
|
test('两边都没有 → null(空串也算没有),调用方的 ?? 默认值才能生效', () { |
||||
|
AppConfig.initialize(build(env: {'AZURE_TRANSLATION_ENDPOINT': ''})); |
||||
|
expect(AppConfig.cred('mt_azure', 'endpoint', 'AZURE_TRANSLATION_ENDPOINT'), isNull); |
||||
|
expect(AppConfig.cred('mt_azure', 'endpoint', 'AZURE_TRANSLATION_ENDPOINT') ?? 'default', 'default'); |
||||
|
expect(AppConfig.usingThirdSvcs, isFalse); |
||||
|
}); |
||||
|
|
||||
|
test('svcsByCategory 按逗号整项比对', () { |
||||
|
AppConfig.initialize(build(svcs: [ |
||||
|
ThirdSvc(id: 'a', name: '', provider: '', categories: '1', fields: {}), |
||||
|
ThirdSvc(id: 'b', name: '', provider: '', categories: '10,11', fields: {}), |
||||
|
])); |
||||
|
expect(AppConfig.svcsByCategory(1).map((s) => s.id), ['a']); |
||||
|
expect(AppConfig.svcsByCategory(11).map((s) => s.id), ['b']); |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,31 @@ |
|||||
|
package comm |
||||
|
|
||||
|
import "testing" |
||||
|
|
||||
|
// AppNamesHas 是产品下发的作用域闸门(DBProduct.Appnames)。两条底线:
|
||||
|
// - 前缀包含关系不能算命中——线上真实存在 EAIMAR 与 EAIMAR-TEST 两个应用名,
|
||||
|
// 任何形式的子串匹配都会让正式应用拿到测试应用的产品(再由客户端挑成绑定 pid);
|
||||
|
// - 空 CSV = 未绑定 = 不限应用,存量产品大多没填这一列,判严会让整批设备绑定/OTA 失效。
|
||||
|
func TestAppNamesHas(t *testing.T) { |
||||
|
cases := []struct { |
||||
|
name string |
||||
|
csv string |
||||
|
app string |
||||
|
want bool |
||||
|
}{ |
||||
|
{"未绑定任何应用即不限应用", "", "EAIMAR", true}, |
||||
|
{"未声明部署身份时不收窄", "EAIMAR", "", true}, |
||||
|
{"单个精确命中", "EAIMAR", "EAIMAR", true}, |
||||
|
{"多个里命中一个", "deepGlass,EAIMAR", "EAIMAR", true}, |
||||
|
{"带空格照样命中", " deepGlass , EAIMAR ", "EAIMAR", true}, |
||||
|
{"不同应用不命中", "deepGlass", "EAIMAR", false}, |
||||
|
{"前缀不算命中(正式不拿测试的产品)", "EAIMAR-TEST", "EAIMAR", false}, |
||||
|
{"反向前缀也不算命中", "EAIMAR", "EAIMAR-TEST", false}, |
||||
|
{"大小写敏感", "eaimar", "EAIMAR", false}, |
||||
|
} |
||||
|
for _, c := range cases { |
||||
|
if got := AppNamesHas(c.csv, c.app); got != c.want { |
||||
|
t.Errorf("%s: AppNamesHas(%q, %q) = %v, want %v", c.name, c.csv, c.app, got, c.want) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,27 +0,0 @@ |
|||||
package api |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/comm" |
|
||||
"yunyan/lego/sys/log" |
|
||||
"yunyan/pb" |
|
||||
) |
|
||||
|
|
||||
// 添加配置
|
|
||||
func (this *apiComp) AddConfig(session comm.IUserSession, req *pb.ApiAddConfigReq) (resp *pb.ApiAddConfigResp, errdata *pb.ErrorData) { |
|
||||
var ( |
|
||||
err error |
|
||||
) |
|
||||
if err = this.module.model.addconfig(req.Config); err != nil { |
|
||||
errdata = &pb.ErrorData{ |
|
||||
Code: pb.ErrorCode_DBError, |
|
||||
Message: err.Error(), |
|
||||
} |
|
||||
this.module.Error("登录失败!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()}) |
|
||||
return |
|
||||
} |
|
||||
if err = this.service.RpcBroadcast(session, comm.Service_Home, string(comm.Rpc_ModifyAppConifg), &pb.Rpc_EmptyReq{}, nil); err != nil { |
|
||||
this.module.Error("广播失败了!", log.Field{Key: "err", Value: err.Error()}) |
|
||||
} |
|
||||
resp = &pb.ApiAddConfigResp{} |
|
||||
return |
|
||||
} |
|
||||
@ -1,21 +0,0 @@ |
|||||
package api |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/comm" |
|
||||
"yunyan/lego/sys/log" |
|
||||
"yunyan/pb" |
|
||||
) |
|
||||
|
|
||||
// 新增唤醒语音
|
|
||||
func (this *apiComp) AddWakeupVoice(session comm.IUserSession, req *pb.ApiAddWakeupVoiceReq) (resp *pb.ApiAddWakeupVoiceResp, errdata *pb.ErrorData) { |
|
||||
var ( |
|
||||
err error |
|
||||
) |
|
||||
if err = this.module.model.addwakeupvoice(req.Voice); err != nil { |
|
||||
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()} |
|
||||
this.module.Error("AddWakeupVoice 失败!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()}) |
|
||||
return |
|
||||
} |
|
||||
resp = &pb.ApiAddWakeupVoiceResp{} |
|
||||
return |
|
||||
} |
|
||||
@ -1,27 +0,0 @@ |
|||||
package api |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/comm" |
|
||||
"yunyan/lego/sys/log" |
|
||||
"yunyan/pb" |
|
||||
) |
|
||||
|
|
||||
// 登录后台
|
|
||||
func (this *apiComp) DelConfig(session comm.IUserSession, req *pb.ApiDelConfigReq) (resp *pb.ApiDelConfigResp, errdata *pb.ErrorData) { |
|
||||
var ( |
|
||||
err error |
|
||||
) |
|
||||
if err = this.module.model.delconfig(req.Id); err != nil { |
|
||||
errdata = &pb.ErrorData{ |
|
||||
Code: pb.ErrorCode_DBError, |
|
||||
Message: err.Error(), |
|
||||
} |
|
||||
this.module.Error("登录失败!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()}) |
|
||||
return |
|
||||
} |
|
||||
if err = this.service.RpcBroadcast(session, comm.Service_Home, string(comm.Rpc_ModifyAppConifg), &pb.Rpc_EmptyReq{}, nil); err != nil { |
|
||||
this.module.Error("广播失败了!", log.Field{Key: "err", Value: err.Error()}) |
|
||||
} |
|
||||
resp = &pb.ApiDelConfigResp{} |
|
||||
return |
|
||||
} |
|
||||
@ -1,21 +0,0 @@ |
|||||
package api |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/comm" |
|
||||
"yunyan/lego/sys/log" |
|
||||
"yunyan/pb" |
|
||||
) |
|
||||
|
|
||||
// 删除唤醒语音
|
|
||||
func (this *apiComp) DelWakeupVoice(session comm.IUserSession, req *pb.ApiDelWakeupVoiceReq) (resp *pb.ApiDelWakeupVoiceResp, errdata *pb.ErrorData) { |
|
||||
var ( |
|
||||
err error |
|
||||
) |
|
||||
if err = this.module.model.delwakeupvoice(req.Id); err != nil { |
|
||||
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()} |
|
||||
this.module.Error("DelWakeupVoice 失败!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()}) |
|
||||
return |
|
||||
} |
|
||||
resp = &pb.ApiDelWakeupVoiceResp{} |
|
||||
return |
|
||||
} |
|
||||
@ -1,28 +0,0 @@ |
|||||
package api |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/comm" |
|
||||
"yunyan/lego/sys/log" |
|
||||
"yunyan/pb" |
|
||||
) |
|
||||
|
|
||||
// 获取配置
|
|
||||
func (this *apiComp) GetConfig(session comm.IUserSession, req *pb.ApiGetConfigReq) (resp *pb.ApiGetConfigResp, errdata *pb.ErrorData) { |
|
||||
var ( |
|
||||
config []*pb.DBAppConfigItem |
|
||||
err error |
|
||||
) |
|
||||
|
|
||||
if config, err = this.module.model.config(); err != nil { |
|
||||
errdata = &pb.ErrorData{ |
|
||||
Code: pb.ErrorCode_DBError, |
|
||||
Message: err.Error(), |
|
||||
} |
|
||||
this.module.Error("登录失败!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()}) |
|
||||
return |
|
||||
} |
|
||||
resp = &pb.ApiGetConfigResp{ |
|
||||
Config: config, |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
@ -1,28 +0,0 @@ |
|||||
package api |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/comm" |
|
||||
"yunyan/lego/sys/log" |
|
||||
"yunyan/pb" |
|
||||
) |
|
||||
|
|
||||
// 获取唤醒语音列表
|
|
||||
func (this *apiComp) GetWakeupVoice(session comm.IUserSession, req *pb.ApiGetWakeupVoiceReq) (resp *pb.ApiGetWakeupVoiceResp, errdata *pb.ErrorData) { |
|
||||
var ( |
|
||||
model *pb.DBWakeupVoice |
|
||||
err error |
|
||||
) |
|
||||
|
|
||||
if model, err = this.module.model.wakeupvoice(req.Id); err != nil { |
|
||||
errdata = &pb.ErrorData{ |
|
||||
Code: pb.ErrorCode_DBError, |
|
||||
Message: err.Error(), |
|
||||
} |
|
||||
this.module.Error("GetWakeupVoice 失败!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()}) |
|
||||
return |
|
||||
} |
|
||||
resp = &pb.ApiGetWakeupVoiceResp{ |
|
||||
Voice: model, |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
@ -1,28 +0,0 @@ |
|||||
package api |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/comm" |
|
||||
"yunyan/lego/sys/log" |
|
||||
"yunyan/pb" |
|
||||
) |
|
||||
|
|
||||
// 获取唤醒语音列表
|
|
||||
func (this *apiComp) GetWakeupVoices(session comm.IUserSession, req *pb.ApiGetWakeupVoicesReq) (resp *pb.ApiGetWakeupVoicesResp, errdata *pb.ErrorData) { |
|
||||
var ( |
|
||||
models []*pb.DBWakeupVoice |
|
||||
err error |
|
||||
) |
|
||||
|
|
||||
if models, err = this.module.model.wakeupvoices(); err != nil { |
|
||||
errdata = &pb.ErrorData{ |
|
||||
Code: pb.ErrorCode_DBError, |
|
||||
Message: err.Error(), |
|
||||
} |
|
||||
this.module.Error("GetWakeupVoices 失败!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()}) |
|
||||
return |
|
||||
} |
|
||||
resp = &pb.ApiGetWakeupVoicesResp{ |
|
||||
Voices: models, |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
@ -1,27 +0,0 @@ |
|||||
package api |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/comm" |
|
||||
"yunyan/lego/sys/log" |
|
||||
"yunyan/pb" |
|
||||
) |
|
||||
|
|
||||
// 更新配置
|
|
||||
func (this *apiComp) UpdateConfig(session comm.IUserSession, req *pb.ApiUpdateConfigReq) (resp *pb.ApiUpdateConfigResp, errdata *pb.ErrorData) { |
|
||||
var ( |
|
||||
err error |
|
||||
) |
|
||||
if err = this.module.model.updateconfig(req.Config); err != nil { |
|
||||
errdata = &pb.ErrorData{ |
|
||||
Code: pb.ErrorCode_DBError, |
|
||||
Message: err.Error(), |
|
||||
} |
|
||||
this.module.Error("UpdateAgent Fail!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()}) |
|
||||
return |
|
||||
} |
|
||||
if err = this.service.RpcBroadcast(session, comm.Service_Home, string(comm.Rpc_ModifyAppConifg), &pb.Rpc_EmptyReq{}, nil); err != nil { |
|
||||
this.module.Error("广播失败了!", log.Field{Key: "err", Value: err.Error()}) |
|
||||
} |
|
||||
resp = &pb.ApiUpdateConfigResp{} |
|
||||
return |
|
||||
} |
|
||||
@ -1,21 +0,0 @@ |
|||||
package api |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/comm" |
|
||||
"yunyan/lego/sys/log" |
|
||||
"yunyan/pb" |
|
||||
) |
|
||||
|
|
||||
// 更新唤醒语音
|
|
||||
func (this *apiComp) UpdateWakeupVoice(session comm.IUserSession, req *pb.ApiUpdateWakeupVoiceReq) (resp *pb.ApiUpdateWakeupVoiceResp, errdata *pb.ErrorData) { |
|
||||
var ( |
|
||||
err error |
|
||||
) |
|
||||
if err = this.module.model.updatewakeupvoice(req.Voice); err != nil { |
|
||||
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()} |
|
||||
this.module.Error("UpdateWakeupVoice 失败!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()}) |
|
||||
return |
|
||||
} |
|
||||
resp = &pb.ApiUpdateWakeupVoiceResp{} |
|
||||
return |
|
||||
} |
|
||||
@ -1,155 +0,0 @@ |
|||||
package console |
|
||||
|
|
||||
import ( |
|
||||
"strings" |
|
||||
|
|
||||
"yunyan/comm" |
|
||||
"yunyan/pb" |
|
||||
|
|
||||
"github.com/gin-gonic/gin" |
|
||||
) |
|
||||
|
|
||||
// ============================ 应用环境配置 (appcfg) ============================
|
|
||||
//
|
|
||||
// 「应用环境配置」页(admin appconfig.vue)的后端:读写**选中部署应用业务库**里的 config 表
|
|
||||
// (comm.TableAppConfig / pb.DBAppConfigItem)——即应用自有的 key/value 配置(音乐、OSS 等)。
|
|
||||
//
|
|
||||
// 结构与「全局环境配置」(global_config, 存 console 公共库、按区域分组)一致,但去掉区域维度:
|
|
||||
// 应用 config 不分区域,且落在各应用自己的业务库里,故按部署 app_id 从 registry 动态取连接
|
|
||||
// (与 modulecfg / analyze / 重置设备码同机制),不经 console 公共库。
|
|
||||
//
|
|
||||
// 无加密字段(与 global_config 一致,明文 key/value)。
|
|
||||
|
|
||||
// appCfgList 列出某部署业务库 config 表的全部配置项(按 group 分组由前端做)。
|
|
||||
func (this *serverComp) appCfgList(c *gin.Context) { |
|
||||
var req struct { |
|
||||
AppId uint32 `json:"app_id"` |
|
||||
} |
|
||||
_ = c.ShouldBindJSON(&req) |
|
||||
if req.AppId == 0 { |
|
||||
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 必填") |
|
||||
return |
|
||||
} |
|
||||
db, err := this.module.registry.getServiceDB(req.AppId) |
|
||||
if err != nil { |
|
||||
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error()) |
|
||||
return |
|
||||
} |
|
||||
if err := db.CreateTable(comm.TableAppConfig, &pb.DBAppConfigItem{}); err != nil { |
|
||||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|
||||
return |
|
||||
} |
|
||||
items := make([]*pb.DBAppConfigItem, 0) |
|
||||
if err := db.Find(comm.TableAppConfig, &items, ""); err != nil { |
|
||||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|
||||
return |
|
||||
} |
|
||||
writeOK(c, gin.H{"items": items}) |
|
||||
} |
|
||||
|
|
||||
// appCfgAdd 在某部署业务库新增一条 config(id 由库自增回填)。
|
|
||||
func (this *serverComp) appCfgAdd(c *gin.Context) { |
|
||||
var req struct { |
|
||||
AppId uint32 `json:"app_id"` |
|
||||
Group string `json:"group"` |
|
||||
Key string `json:"key"` |
|
||||
Value string `json:"value"` |
|
||||
Description string `json:"description"` |
|
||||
} |
|
||||
if err := c.ShouldBindJSON(&req); err != nil { |
|
||||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|
||||
return |
|
||||
} |
|
||||
if req.AppId == 0 { |
|
||||
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 必填") |
|
||||
return |
|
||||
} |
|
||||
if strings.TrimSpace(req.Key) == "" { |
|
||||
writeErr(c, pb.ErrorCode_ReqParameterError, "键(key) 必填") |
|
||||
return |
|
||||
} |
|
||||
db, err := this.module.registry.getServiceDB(req.AppId) |
|
||||
if err != nil { |
|
||||
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error()) |
|
||||
return |
|
||||
} |
|
||||
if err := db.CreateTable(comm.TableAppConfig, &pb.DBAppConfigItem{}); err != nil { |
|
||||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|
||||
return |
|
||||
} |
|
||||
m := &pb.DBAppConfigItem{ |
|
||||
Group: strings.TrimSpace(req.Group), |
|
||||
Key: strings.TrimSpace(req.Key), |
|
||||
Value: req.Value, |
|
||||
Description: strings.TrimSpace(req.Description), |
|
||||
} |
|
||||
if err := db.Insert(comm.TableAppConfig, m); err != nil { |
|
||||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|
||||
return |
|
||||
} |
|
||||
writeOK(c, m) |
|
||||
} |
|
||||
|
|
||||
// appCfgUpdate 更新某部署业务库的一条 config(按 id)。
|
|
||||
func (this *serverComp) appCfgUpdate(c *gin.Context) { |
|
||||
var req struct { |
|
||||
AppId uint32 `json:"app_id"` |
|
||||
Id uint64 `json:"id"` |
|
||||
Group string `json:"group"` |
|
||||
Key string `json:"key"` |
|
||||
Value string `json:"value"` |
|
||||
Description string `json:"description"` |
|
||||
} |
|
||||
if err := c.ShouldBindJSON(&req); err != nil { |
|
||||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|
||||
return |
|
||||
} |
|
||||
if req.AppId == 0 || req.Id == 0 { |
|
||||
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 与 id 必填") |
|
||||
return |
|
||||
} |
|
||||
db, err := this.module.registry.getServiceDB(req.AppId) |
|
||||
if err != nil { |
|
||||
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error()) |
|
||||
return |
|
||||
} |
|
||||
m := &pb.DBAppConfigItem{ |
|
||||
Id: req.Id, |
|
||||
Group: strings.TrimSpace(req.Group), |
|
||||
Key: strings.TrimSpace(req.Key), |
|
||||
Value: req.Value, |
|
||||
Description: strings.TrimSpace(req.Description), |
|
||||
} |
|
||||
if err := db.Save(comm.TableAppConfig, m); err != nil { |
|
||||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|
||||
return |
|
||||
} |
|
||||
writeOK(c, m) |
|
||||
} |
|
||||
|
|
||||
// appCfgDel 按 id 批量删除某部署业务库的 config。
|
|
||||
func (this *serverComp) appCfgDel(c *gin.Context) { |
|
||||
var req struct { |
|
||||
AppId uint32 `json:"app_id"` |
|
||||
Ids []uint64 `json:"ids"` |
|
||||
} |
|
||||
_ = c.ShouldBindJSON(&req) |
|
||||
if req.AppId == 0 { |
|
||||
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 必填") |
|
||||
return |
|
||||
} |
|
||||
if len(req.Ids) == 0 { |
|
||||
writeErr(c, pb.ErrorCode_ReqParameterError, "ids 必填") |
|
||||
return |
|
||||
} |
|
||||
db, err := this.module.registry.getServiceDB(req.AppId) |
|
||||
if err != nil { |
|
||||
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error()) |
|
||||
return |
|
||||
} |
|
||||
if err := db.Delete(comm.TableAppConfig, "id IN ?", req.Ids); err != nil { |
|
||||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|
||||
return |
|
||||
} |
|
||||
writeOK(c, gin.H{"deleted": len(req.Ids)}) |
|
||||
} |
|
||||
@ -0,0 +1,171 @@ |
|||||
|
package console |
||||
|
|
||||
|
import ( |
||||
|
"strconv" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
"yunyan/comm" |
||||
|
"yunyan/lego/sys/log" |
||||
|
"yunyan/lego/sys/postgres" |
||||
|
"yunyan/pb" |
||||
|
|
||||
|
"github.com/gin-gonic/gin" |
||||
|
) |
||||
|
|
||||
|
// ============================ 第三方服务类别 CRUD ============================
|
||||
|
//
|
||||
|
// 后台「服务与环境配置 → 第三方服务 → 类别管理」的后端。规则见 model_svccategory.go 头部注释:
|
||||
|
// 内置类别只能改展示属性,自定义类别可增删改,删除前查引用。
|
||||
|
|
||||
|
// seedSvcCategories 幂等 seed 内置类别:不存在则插入;已存在只补 builtin/voice 两个语义标记,
|
||||
|
// 展示属性(label/short/color/sort/enabled)一律保留运营改过的值。启动时跑一次。
|
||||
|
func seedSvcCategories() { |
||||
|
inserted := 0 |
||||
|
for _, c := range builtinSvcCategories() { |
||||
|
exist := &SvcCategory{} |
||||
|
if err := postgres.FindOne(comm.TableSvcCategory, exist, "id=?", c.Id); err == nil { |
||||
|
if exist.Builtin != true || exist.Voice != c.Voice { |
||||
|
exist.Builtin = true |
||||
|
exist.Voice = c.Voice |
||||
|
_ = postgres.Save(comm.TableSvcCategory, exist) |
||||
|
} |
||||
|
continue |
||||
|
} |
||||
|
if err := postgres.Insert(comm.TableSvcCategory, &c); err != nil { |
||||
|
log.Warnf("console: seed 服务类别 %d 失败: %v", c.Id, err) |
||||
|
continue |
||||
|
} |
||||
|
inserted++ |
||||
|
} |
||||
|
if inserted > 0 { |
||||
|
log.Infof("console: 内置服务类别 seed 完成 inserted=%d", inserted) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// getSvcCategories 全部类别(含停用的,前端自己按 enabled 决定在哪些地方展示),按 sort,id 升序。
|
||||
|
func (this *serverComp) getSvcCategories(c *gin.Context) { |
||||
|
items := make([]*SvcCategory, 0, 16) |
||||
|
if err := postgres.Table(comm.TableSvcCategory).Order("sort ASC, id ASC").Find(&items).Error; err != nil { |
||||
|
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
||||
|
return |
||||
|
} |
||||
|
for _, it := range items { |
||||
|
fillSvcCategoryRuntime(it) |
||||
|
} |
||||
|
writeOK(c, gin.H{"items": items}) |
||||
|
} |
||||
|
|
||||
|
// saveSvcCategory 新增或更新一个类别。
|
||||
|
// - id=0 → 新增自定义类别,id 由服务端从 100 起分配;
|
||||
|
// - id>0 → 更新:内置类别只接受展示属性,builtin/id 不可改;voice 内置的也不让改
|
||||
|
// (音色预填是按类别语义定的,改了新建 TTS 服务时 languages 就不预填了)。
|
||||
|
func (this *serverComp) saveSvcCategory(c *gin.Context) { |
||||
|
var m SvcCategory |
||||
|
if err := c.ShouldBindJSON(&m); err != nil { |
||||
|
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
||||
|
return |
||||
|
} |
||||
|
m.Label = strings.TrimSpace(m.Label) |
||||
|
m.Short = strings.TrimSpace(m.Short) |
||||
|
if m.Label == "" || m.Short == "" { |
||||
|
writeErr(c, pb.ErrorCode_ReqParameterError, "名称与角标 必填") |
||||
|
return |
||||
|
} |
||||
|
if len([]rune(m.Short)) > 8 { |
||||
|
writeErr(c, pb.ErrorCode_ReqParameterError, "角标最多 8 个字符(卡片上放不下)") |
||||
|
return |
||||
|
} |
||||
|
if m.Color == "" { |
||||
|
m.Color = "#475569" |
||||
|
} |
||||
|
if m.Bg == "" { |
||||
|
m.Bg = "#f1f5f9" |
||||
|
} |
||||
|
|
||||
|
if m.Id == 0 { |
||||
|
// 新增:分配 id。max(id, 99)+1 保证自定义从 100 起,与内置永不相撞。
|
||||
|
var maxId int32 |
||||
|
_ = postgres.Table(comm.TableSvcCategory).Select("COALESCE(MAX(id), 0)").Scan(&maxId).Error |
||||
|
if maxId < svcCategoryCustomIdStart-1 { |
||||
|
maxId = svcCategoryCustomIdStart - 1 |
||||
|
} |
||||
|
m.Id = maxId + 1 |
||||
|
m.Builtin = false |
||||
|
if err := postgres.Insert(comm.TableSvcCategory, &m); err != nil { |
||||
|
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
||||
|
return |
||||
|
} |
||||
|
fillSvcCategoryRuntime(&m) |
||||
|
writeOK(c, &m) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
old := &SvcCategory{} |
||||
|
if err := postgres.FindOne(comm.TableSvcCategory, old, "id=?", m.Id); err != nil { |
||||
|
writeErr(c, pb.ErrorCode_DBError, "类别不存在: "+strconv.Itoa(int(m.Id))) |
||||
|
return |
||||
|
} |
||||
|
// 语义标记只认库里的:内置的 builtin/voice 不可改,自定义的 voice 可改、builtin 恒 false。
|
||||
|
m.Builtin = old.Builtin |
||||
|
if old.Builtin { |
||||
|
m.Voice = old.Voice |
||||
|
} |
||||
|
if err := postgres.Save(comm.TableSvcCategory, &m); err != nil { |
||||
|
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
||||
|
return |
||||
|
} |
||||
|
fillSvcCategoryRuntime(&m) |
||||
|
writeOK(c, &m) |
||||
|
} |
||||
|
|
||||
|
// delSvcCategory 删除自定义类别。内置类别拒绝;被服务或模板引用的也拒绝,并把引用列出来。
|
||||
|
func (this *serverComp) delSvcCategory(c *gin.Context) { |
||||
|
var req struct { |
||||
|
Id int32 `json:"id"` |
||||
|
} |
||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.Id == 0 { |
||||
|
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") |
||||
|
return |
||||
|
} |
||||
|
old := &SvcCategory{} |
||||
|
if err := postgres.FindOne(comm.TableSvcCategory, old, "id=?", req.Id); err != nil { |
||||
|
writeErr(c, pb.ErrorCode_DBError, "类别不存在") |
||||
|
return |
||||
|
} |
||||
|
if old.Builtin { |
||||
|
writeErr(c, pb.ErrorCode_ReqParameterError, |
||||
|
"内置类别不能删除:运行时与内置模板按这个 id 引用(如 MT=3 用于 user_translate、实名=11 决定不下发)。不想用可以停用。") |
||||
|
return |
||||
|
} |
||||
|
// 引用检查:服务实例的 categories 是逗号分隔多值,逐条拆开比对,别用 LIKE('1' 会命中 '10'/'11')。
|
||||
|
svcs := make([]*ThirdSvcConfig, 0) |
||||
|
_ = postgres.Find(comm.TableSvcConfig, &svcs, "") |
||||
|
refs := make([]string, 0, 4) |
||||
|
for _, s := range svcs { |
||||
|
if categoriesHas(s.Categories, req.Id) { |
||||
|
scope := s.AppName |
||||
|
if scope == "" { |
||||
|
scope = "<全局>" |
||||
|
} |
||||
|
refs = append(refs, "服务 "+s.Id+"("+scope+")") |
||||
|
} |
||||
|
} |
||||
|
tpls := make([]*ThirdSvcTemplate, 0) |
||||
|
_ = postgres.Find(comm.TableThirdSvcTemplate, &tpls, "category=?", req.Id) |
||||
|
for _, t := range tpls { |
||||
|
refs = append(refs, "模板 "+t.Id) |
||||
|
} |
||||
|
if len(refs) > 0 { |
||||
|
if len(refs) > 6 { |
||||
|
refs = append(refs[:6], "…等 "+strconv.Itoa(len(refs))+" 处") |
||||
|
} |
||||
|
writeErr(c, pb.ErrorCode_ReqParameterError, "该类别仍被引用,先把它们改到别的类别:"+strings.Join(refs, "、")) |
||||
|
return |
||||
|
} |
||||
|
if err := postgres.Delete(comm.TableSvcCategory, "id=?", req.Id); err != nil { |
||||
|
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
||||
|
return |
||||
|
} |
||||
|
writeOK(c, gin.H{"deleted": 1, "at": time.Now().Unix()}) |
||||
|
} |
||||
@ -0,0 +1,301 @@ |
|||||
|
package console |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"sort" |
||||
|
"strconv" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
"yunyan/comm" |
||||
|
"yunyan/lego/sys/log" |
||||
|
"yunyan/lego/sys/mysql" |
||||
|
"yunyan/lego/sys/postgres" |
||||
|
"yunyan/pb" |
||||
|
) |
||||
|
|
||||
|
// ============================ 应用参数归一:业务库 config 表 → svc_config ============================
|
||||
|
//
|
||||
|
// 2026-09-14 后台「服务与环境配置」的「应用参数」标签页下线。它是业务库 config 表的裸 key/value 编辑器,
|
||||
|
// 而 user_getappconfig 原先把这张表**整张**塞进 env 下发——COS 密钥、灵犀产品密钥、连「会员与算力」页
|
||||
|
// 写进去的 COMPUTE_RATE_* 都会明文发到每个客户端手里。逐项核过两台机的 22 行之后的处置:
|
||||
|
//
|
||||
|
// - 客户端还在读的键搬进 svc_config,字段带 env_key、仍按老键名下发(客户端零改动即可):
|
||||
|
// AGENT_TYPE / MOBILE_ELF_AGENT_ID / MOBILE_ELF_PRODUCT_ID / MOBILE_ELF_PRODUCT_KEY / YIDONG_PID
|
||||
|
// → 服务 llm_mobile_elf(中国移动 灵犀 / 移动精灵)
|
||||
|
// iapCustomerServiceQQ → 服务 app_params(应用参数)
|
||||
|
// - 谁都不读的死键直接删(appCfgDeadKeys:音乐 / Spotify / 公码 / 导航 / 腾讯 COS / MeetServers);
|
||||
|
// COS 那 5 个是腾讯云直传时代的密钥,上传 2026-09-04 起走 OSS 预签名,且已确认线上没有老包在读。
|
||||
|
// - 服务端自用的(算力系数 / 运营参数 / 迁移标记)留在表里,由「会员与算力」页维护,**不再下发**。
|
||||
|
// - 其余不认识的键原样保留并告警:它们已经没有任何后台入口、也不下发了,
|
||||
|
// 要么加进 appParamsPlan 落点表,要么手工清。
|
||||
|
//
|
||||
|
// 服务实例照内置模板建(builtinSvcTemplates 里的 llm_mobile_elf / app_params),值从 config 表取。
|
||||
|
// 幂等、每次启动跑:服务已存在只补空字段;config 表里的行只在**落库成功之后**才删。
|
||||
|
//
|
||||
|
// 必须排在 migrateGlobalScopeToApps 之后:直接写 app_name=<应用名> 的行。作用域键同那里一样是
|
||||
|
// app_registry.app_name(不是部署名 name)。同一应用有多个部署(多套业务库)时按 id 升序,
|
||||
|
// 第一个部署的值为准,后面的只清表。
|
||||
|
|
||||
|
// appParamFieldSpec config 表里的一个键在服务上的落点。ConfigKey 同时也是字段的下发键名(env_key)。
|
||||
|
type appParamFieldSpec struct { |
||||
|
ConfigKey string |
||||
|
FieldKey string |
||||
|
} |
||||
|
|
||||
|
// appParamSvcSpec 一个落点服务。模板 id 指向 builtinSvcTemplates,名称/类别/字段定义都从模板取。
|
||||
|
type appParamSvcSpec struct { |
||||
|
TplId string |
||||
|
CreateIfEmpty bool // config 表里一个值都没有时是否也建:app_params 要建出来让运营有地方填;移动精灵没值就不建
|
||||
|
Fields []appParamFieldSpec |
||||
|
} |
||||
|
|
||||
|
var appParamsPlan = []appParamSvcSpec{ |
||||
|
{ |
||||
|
TplId: "llm_mobile_elf", |
||||
|
Fields: []appParamFieldSpec{ |
||||
|
{ConfigKey: "AGENT_TYPE", FieldKey: "agent_type"}, |
||||
|
{ConfigKey: "MOBILE_ELF_AGENT_ID", FieldKey: "agent_id"}, |
||||
|
{ConfigKey: "MOBILE_ELF_PRODUCT_ID", FieldKey: "product_id"}, |
||||
|
{ConfigKey: "MOBILE_ELF_PRODUCT_KEY", FieldKey: "product_key"}, |
||||
|
{ConfigKey: "YIDONG_PID", FieldKey: "pid"}, |
||||
|
}, |
||||
|
}, |
||||
|
{ |
||||
|
TplId: "app_params", CreateIfEmpty: true, |
||||
|
Fields: []appParamFieldSpec{ |
||||
|
{ConfigKey: "iapCustomerServiceQQ", FieldKey: "iap_customer_service_qq"}, |
||||
|
}, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
// appCfgDeadKeys 确认服务端、客户端(含原生插件)、后台都没有读取方的键 → 原因。
|
||||
|
// 判定时间 2026-09-14,方法是对 apps/services、apps/client/lib、local_plugins、apps/admin 全文 grep。
|
||||
|
var appCfgDeadKeys = map[string]string{ |
||||
|
"APP_MUSICSERVICE_TYPE": "音乐模块已删", |
||||
|
"APP_ANDROID_MUSICSERVICE_TYPE": "音乐模块已删", |
||||
|
"APP_IOS_MUSICSERVICE_TYPE": "音乐模块已删", |
||||
|
"SPOTIFY_CLIENT_ID": "音乐模块已删", |
||||
|
"SPOTIFY_REDIRECT_URL": "音乐模块已删", |
||||
|
"APP_AUTHCODE_OPEN": "厂家公码已下线", |
||||
|
"APP_AUTHCODE_JUMP": "厂家公码已下线", |
||||
|
"APP_NAVIGATION_MODE": "老 agent 模块已删,无人读取", |
||||
|
"COS_APP_ID": "上传已改 OSS 预签名,且无老包", |
||||
|
"COS_BUCKET_NAME": "上传已改 OSS 预签名,且无老包", |
||||
|
"COS_REGION": "上传已改 OSS 预签名,且无老包", |
||||
|
"COS_SECRET_ID": "上传已改 OSS 预签名,且无老包", |
||||
|
"COS_SECRET_KEY": "上传已改 OSS 预签名,且无老包", |
||||
|
"MeetServers": "会议选型走 echomeet_orch,无人读取", |
||||
|
} |
||||
|
|
||||
|
// appCfgServerKeys 服务端自用、要留在 config 表里的键(由「会员与算力」页维护;迁移标记由 home 写)。
|
||||
|
func appCfgServerKeys() map[string]bool { |
||||
|
return map[string]bool{ |
||||
|
comm.ConfigKeyComputeRateTranslate: true, |
||||
|
comm.ConfigKeyComputeRateMeeting: true, |
||||
|
comm.ConfigKeyComputeRateAIChat: true, |
||||
|
comm.ConfigKeyComputeGate: true, |
||||
|
comm.ConfigKeyNewUserGiftVipDays: true, |
||||
|
comm.ConfigKeyNewUserGiftCompute: true, |
||||
|
comm.ConfigKeyVipWarnDays: true, |
||||
|
comm.ConfigKeyComputeWarn: true, |
||||
|
"COMPUTE_LEGACY_MIGRATED": true, // modules/user/model_user.go legacyComputeMigratedKey
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func migrateAppParamsToSvc(encKey string, apps []*AppRegistry) { |
||||
|
sort.Slice(apps, func(i, j int) bool { return apps[i].Id < apps[j].Id }) |
||||
|
seenScope := map[string]bool{} |
||||
|
for _, app := range apps { |
||||
|
if !app.Enabled || app.ServiceDsn == "" { |
||||
|
continue |
||||
|
} |
||||
|
scope := strings.TrimSpace(app.AppName) |
||||
|
if scope == "" { |
||||
|
log.Warnf("console.appparams: 部署 %s 未归属应用(app_name 为空),它的 config 表不处理", app.Name) |
||||
|
continue |
||||
|
} |
||||
|
db, err := openDriver(app.ServiceDsn) |
||||
|
if err != nil { |
||||
|
log.Warnf("console.appparams: 部署 %s 业务库连接失败,跳过: %v", app.Name, err) |
||||
|
continue |
||||
|
} |
||||
|
items := make([]*pb.DBAppConfigItem, 0) |
||||
|
if err := db.Find(comm.TableAppConfig, &items, ""); err != nil { |
||||
|
log.Warnf("console.appparams: 部署 %s 读 config 表失败,跳过: %v", app.Name, err) |
||||
|
continue |
||||
|
} |
||||
|
if len(items) == 0 { |
||||
|
continue |
||||
|
} |
||||
|
vals := map[string]string{} |
||||
|
for _, it := range items { |
||||
|
k, v := normalizeAppCfgRow(it) |
||||
|
if k == "" { |
||||
|
continue |
||||
|
} |
||||
|
if old, ok := vals[k]; !ok || (strings.TrimSpace(old) == "" && strings.TrimSpace(v) != "") { |
||||
|
vals[k] = v |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
migrated := map[string]bool{} |
||||
|
for _, spec := range appParamsPlan { |
||||
|
// 同一应用的第二个部署:服务行已由第一个部署建好,这里只把键标成已迁走去清表,不再覆盖值。
|
||||
|
keys, err := upsertAppParamSvc(scope, spec, vals, encKey, seenScope[scope]) |
||||
|
if err != nil { |
||||
|
log.Warnf("console.appparams: 应用 %s 服务 %s 迁移失败: %v", scope, spec.TplId, err) |
||||
|
continue |
||||
|
} |
||||
|
for k := range keys { |
||||
|
migrated[k] = true |
||||
|
} |
||||
|
} |
||||
|
seenScope[scope] = true |
||||
|
cleanAppParamRows(app.Name, db, items, migrated) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// upsertAppParamSvc 建或补一个落点服务,返回这次确认已落到 svc_config 里的 config 键。
|
||||
|
// - 服务不存在:按模板建;config 里一个值都没有且 !CreateIfEmpty 则不建(返回空)。
|
||||
|
// - 服务已存在:只给空着的字段填值、补缺失的字段与 env_key;运营填过的值不覆盖。
|
||||
|
// - 只要字段在服务上存在(不管值是取自 config 还是运营填的),对应 config 键就算迁走——
|
||||
|
// svc_config 从此是唯一出处,config 表那行留着只会误导。
|
||||
|
//
|
||||
|
// ⚠️ 返回的键只在落库成功之后才有效,调用方拿它去删 config 表——提前返回会让两张表都没值。
|
||||
|
func upsertAppParamSvc(scope string, spec appParamSvcSpec, vals map[string]string, encKey string, scopeSeen bool) (map[string]bool, error) { |
||||
|
tpl := findBuiltinTemplate(spec.TplId) |
||||
|
if tpl == nil { |
||||
|
return nil, fmt.Errorf("内置模板 %s 不存在", spec.TplId) |
||||
|
} |
||||
|
hasValue := false |
||||
|
for _, f := range spec.Fields { |
||||
|
if strings.TrimSpace(vals[f.ConfigKey]) != "" { |
||||
|
hasValue = true |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
now := time.Now().Unix() |
||||
|
svc := &comm.ThirdSvcConfig{} |
||||
|
exists := true |
||||
|
if err := postgres.FindOne(comm.TableSvcConfig, svc, "app_name=? AND id=?", scope, tpl.Id); err != nil { |
||||
|
if err != postgres.ErrNoDocuments { |
||||
|
return nil, err |
||||
|
} |
||||
|
exists = false |
||||
|
if !hasValue && !spec.CreateIfEmpty { |
||||
|
return nil, nil |
||||
|
} |
||||
|
fields := make([]comm.SvcField, len(tpl.Fields)) |
||||
|
copy(fields, tpl.Fields) |
||||
|
svc = &comm.ThirdSvcConfig{ |
||||
|
AppName: scope, Id: tpl.Id, Name: tpl.Name, Provider: tpl.Provider, |
||||
|
Categories: strconv.Itoa(int(tpl.Category)), Description: tpl.Description, |
||||
|
Enable: true, Fields: fields, Createtime: now, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
idx := map[string]int{} |
||||
|
for i, f := range svc.Fields { |
||||
|
idx[f.Key] = i |
||||
|
} |
||||
|
done := map[string]bool{} |
||||
|
changed := !exists |
||||
|
// 同一应用的后续部署只清表不写值——除非服务是这次才建的(首个部署没值没建)。
|
||||
|
takeValues := !scopeSeen || !exists |
||||
|
for _, fs := range spec.Fields { |
||||
|
i, ok := idx[fs.FieldKey] |
||||
|
if !ok { |
||||
|
// 模板里没有的字段(模板被运营改过):按明文补一个,别让值丢了。
|
||||
|
svc.Fields = append(svc.Fields, comm.SvcField{Key: fs.FieldKey, Description: fs.ConfigKey, Sort: int32(len(svc.Fields))}) |
||||
|
i = len(svc.Fields) - 1 |
||||
|
idx[fs.FieldKey] = i |
||||
|
changed = true |
||||
|
} |
||||
|
f := &svc.Fields[i] |
||||
|
if f.EnvKey == "" { |
||||
|
f.EnvKey = fs.ConfigKey |
||||
|
changed = true |
||||
|
} |
||||
|
if v := vals[fs.ConfigKey]; takeValues && strings.TrimSpace(v) != "" && strings.TrimSpace(f.DefValue) == "" { |
||||
|
stored, err := storeVal(v, f.Encrypted, encKey) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("字段 %s 加密失败: %w", fs.FieldKey, err) |
||||
|
} |
||||
|
f.DefValue = stored |
||||
|
changed = true |
||||
|
} |
||||
|
done[fs.ConfigKey] = true |
||||
|
} |
||||
|
// 服务停用 = 这些键一个都不下发。既然 config 表里有值在用,就得让它继续生效(同 migrate_envtosvc 对 stt_azure 的处理)。
|
||||
|
if exists && !svc.Enable && hasValue && takeValues { |
||||
|
svc.Enable = true |
||||
|
changed = true |
||||
|
log.Infof("console.appparams: 应用 %s 的服务 %s 原为停用,因承接了 config 表在用的键已启用", scope, tpl.Id) |
||||
|
} |
||||
|
if changed { |
||||
|
svc.Updatetime = now |
||||
|
var err error |
||||
|
if exists { |
||||
|
err = postgres.Save(comm.TableSvcConfig, svc) |
||||
|
} else { |
||||
|
err = postgres.Insert(comm.TableSvcConfig, svc) |
||||
|
} |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
log.Infof("console.appparams: 应用 %s 服务 %s %s(承接 config 键 %d 个)", scope, tpl.Id, map[bool]string{true: "已补字段", false: "已建"}[exists], len(done)) |
||||
|
} |
||||
|
return done, nil |
||||
|
} |
||||
|
|
||||
|
// findBuiltinTemplate 按 id 取内置模板定义(代码里的,不查库——模板表里的可能被运营改过)。
|
||||
|
func findBuiltinTemplate(id string) *ThirdSvcTemplate { |
||||
|
for _, t := range builtinSvcTemplates() { |
||||
|
if t.Id == id { |
||||
|
tt := t |
||||
|
return &tt |
||||
|
} |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// cleanAppParamRows 删已迁走的与确认死掉的行;服务端自用的留下;不认识的留下并告警。
|
||||
|
func cleanAppParamRows(deployName string, db mysql.ISys, items []*pb.DBAppConfigItem, migrated map[string]bool) { |
||||
|
serverKeys := appCfgServerKeys() |
||||
|
dropIds := make([]uint64, 0) |
||||
|
dropKeys := make([]string, 0) |
||||
|
unknown := make([]string, 0) |
||||
|
for _, it := range items { |
||||
|
k, _ := normalizeAppCfgRow(it) |
||||
|
if k == "" { |
||||
|
continue |
||||
|
} |
||||
|
reason := "" |
||||
|
switch { |
||||
|
case migrated[k]: |
||||
|
reason = "已迁入 svc_config" |
||||
|
case appCfgDeadKeys[k] != "": |
||||
|
reason = appCfgDeadKeys[k] |
||||
|
case serverKeys[k]: |
||||
|
continue |
||||
|
default: |
||||
|
unknown = append(unknown, k) |
||||
|
continue |
||||
|
} |
||||
|
dropIds = append(dropIds, it.Id) |
||||
|
dropKeys = append(dropKeys, k+"("+reason+")") |
||||
|
} |
||||
|
if len(dropIds) > 0 { |
||||
|
if err := db.Delete(comm.TableAppConfig, "id IN ?", dropIds); err != nil { |
||||
|
log.Warnf("console.appparams: 部署 %s 清理 config 表失败: %v", deployName, err) |
||||
|
} else { |
||||
|
log.Infof("console.appparams: 部署 %s 的 config 表清掉 %d 行:%s", deployName, len(dropKeys), strings.Join(dropKeys, ", ")) |
||||
|
} |
||||
|
} |
||||
|
if len(unknown) > 0 { |
||||
|
log.Warnf("console.appparams: 部署 %s 的 config 表还有 %d 个后台已无入口、也不再下发的键:%s——"+ |
||||
|
"要么加进 migrate_appparams.go 的落点表,要么手工删", deployName, len(unknown), strings.Join(unknown, ", ")) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,263 @@ |
|||||
|
package console |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"sort" |
||||
|
"strings" |
||||
|
|
||||
|
"yunyan/comm" |
||||
|
"yunyan/lego/sys/log" |
||||
|
"yunyan/lego/sys/postgres" |
||||
|
) |
||||
|
|
||||
|
// ============================ 作用域归一:全局层 → 每个应用一套 ============================
|
||||
|
//
|
||||
|
// 2026-09-14 之前第三方服务配置是「全局默认(app_name='') + 应用覆盖」:应用没配的服务回退到全局行。
|
||||
|
// 两台机上的全部服务/区域分叉/会议编排/会议模板都在全局层,没有一个应用真正有自己的一套。
|
||||
|
// 按要求改成**每个应用一套、互不干涉**:应用 A 删掉某个服务,不会悄悄回退到别人那份。
|
||||
|
//
|
||||
|
// 迁移把全局层的行分给每个已启用的应用:
|
||||
|
// - **第一个应用(app_registry 里 id 最小的)直接把 app_name 从 '' 改成它**,行本身不动——
|
||||
|
// 行 id/row_id 全部保留,svc_health、echomeet_record 等按 id 引用的数据不受影响;
|
||||
|
// - 其余应用各拷一份(新 id),已存在的 (app, key) 不覆盖。
|
||||
|
// 跑完之后全局层为空,运行时那些 `OR app_name=''` 的回退分支再也匹配不到任何行。
|
||||
|
//
|
||||
|
// 幂等:全局层空了就整体跳过。每次启动跑。
|
||||
|
//
|
||||
|
// ⚠️ 作用域键是 app_registry.**app_name**(= 应用中心的应用名,如 EAIMAR / deepGlass),不是
|
||||
|
// app_registry.name(部署名,如 deepglass)。运行时用 ANALYZE_APP_NAME 找自己的行,
|
||||
|
// 实测它等于 app_name;用错列会让 deepGlass 一个服务都读不到。
|
||||
|
//
|
||||
|
// 涉及的表(都以 app_name 为作用域键):
|
||||
|
// svc_config / svc_region_override / echomeet_orch / echomeet_orch_setting / echomeet_template(source=public)
|
||||
|
|
||||
|
func migrateGlobalScopeToApps(apps []*AppRegistry) { |
||||
|
targets := scopeTargetApps(apps) |
||||
|
if len(targets) == 0 { |
||||
|
log.Infof("console.scope: 没有已启用的应用,全局层原样保留") |
||||
|
return |
||||
|
} |
||||
|
first, rest := targets[0], targets[1:] |
||||
|
|
||||
|
n1, err := migrateScopeSvcConfig(first, rest) |
||||
|
if err != nil { |
||||
|
log.Warnf("console.scope: svc_config 归一失败: %v", err) |
||||
|
} |
||||
|
n2, err := migrateScopeRegionOverride(first, rest) |
||||
|
if err != nil { |
||||
|
log.Warnf("console.scope: svc_region_override 归一失败: %v", err) |
||||
|
} |
||||
|
n3, err := migrateScopeEchoOrch(first, rest) |
||||
|
if err != nil { |
||||
|
log.Warnf("console.scope: echomeet_orch 归一失败: %v", err) |
||||
|
} |
||||
|
n4, err := migrateScopeEchoOrchSetting(first, rest) |
||||
|
if err != nil { |
||||
|
log.Warnf("console.scope: echomeet_orch_setting 归一失败: %v", err) |
||||
|
} |
||||
|
n5, err := migrateScopeMeetTemplate(first, rest) |
||||
|
if err != nil { |
||||
|
log.Warnf("console.scope: echomeet_template 归一失败: %v", err) |
||||
|
} |
||||
|
if n1+n2+n3+n4+n5 > 0 { |
||||
|
log.Infof("console.scope: 全局层已分给各应用(首个应用 %s 原行改名,其余 %v 各拷一份):"+ |
||||
|
"svc_config %d / 区域分叉 %d / 会议编排 %d / 编排开关 %d / 会议模板 %d", |
||||
|
first, rest, n1, n2, n3, n4, n5) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// scopeTargetApps 已启用应用的 app_name 去重,按 app_registry.id 升序(决定谁拿原行)。
|
||||
|
func scopeTargetApps(apps []*AppRegistry) []string { |
||||
|
sort.Slice(apps, func(i, j int) bool { return apps[i].Id < apps[j].Id }) |
||||
|
seen := map[string]bool{} |
||||
|
out := make([]string, 0, len(apps)) |
||||
|
for _, a := range apps { |
||||
|
n := strings.TrimSpace(a.AppName) |
||||
|
if !a.Enabled || n == "" || seen[n] { |
||||
|
continue |
||||
|
} |
||||
|
seen[n] = true |
||||
|
out = append(out, n) |
||||
|
} |
||||
|
return out |
||||
|
} |
||||
|
|
||||
|
// migrateScopeSvcConfig 服务实例。返回处理的全局行数。
|
||||
|
func migrateScopeSvcConfig(first string, rest []string) (int, error) { |
||||
|
rows := make([]*ThirdSvcConfig, 0) |
||||
|
if err := postgres.Find(comm.TableSvcConfig, &rows, "app_name=?", ""); err != nil && err != postgres.ErrNoDocuments { |
||||
|
return 0, err |
||||
|
} |
||||
|
if len(rows) == 0 { |
||||
|
return 0, nil |
||||
|
} |
||||
|
for _, r := range rows { |
||||
|
for _, app := range rest { |
||||
|
exist := &ThirdSvcConfig{} |
||||
|
if err := postgres.FindOne(comm.TableSvcConfig, exist, "app_name=? AND id=?", app, r.Id); err == nil { |
||||
|
continue |
||||
|
} |
||||
|
cp := *r |
||||
|
cp.RowId = 0 |
||||
|
cp.AppName = app |
||||
|
if err := postgres.Insert(comm.TableSvcConfig, &cp); err != nil { |
||||
|
return 0, fmt.Errorf("拷贝 %s → %s: %w", r.Id, app, err) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
// 拷完再改名(逐行):首个应用已有同 id 的(上次跑到一半留下的)就删掉全局行,否则改名。
|
||||
|
for _, r := range rows { |
||||
|
exist := &ThirdSvcConfig{} |
||||
|
if postgres.FindOne(comm.TableSvcConfig, exist, "app_name=? AND id=?", first, r.Id) == nil { |
||||
|
_ = postgres.Delete(comm.TableSvcConfig, "row_id=?", r.RowId) |
||||
|
continue |
||||
|
} |
||||
|
if err := postgres.Table(comm.TableSvcConfig).Where("row_id=?", r.RowId).Update("app_name", first).Error; err != nil { |
||||
|
return 0, err |
||||
|
} |
||||
|
} |
||||
|
return len(rows), nil |
||||
|
} |
||||
|
|
||||
|
func migrateScopeRegionOverride(first string, rest []string) (int, error) { |
||||
|
rows := make([]*SvcRegionOverride, 0) |
||||
|
if err := postgres.Find(comm.TableSvcRegionOverride, &rows, "app_name=?", ""); err != nil && err != postgres.ErrNoDocuments { |
||||
|
return 0, err |
||||
|
} |
||||
|
if len(rows) == 0 { |
||||
|
return 0, nil |
||||
|
} |
||||
|
for _, r := range rows { |
||||
|
for _, app := range rest { |
||||
|
exist := &SvcRegionOverride{} |
||||
|
if err := postgres.FindOne(comm.TableSvcRegionOverride, exist, |
||||
|
"app_name=? AND svc_id=? AND region=?", app, r.SvcId, r.Region); err == nil { |
||||
|
continue |
||||
|
} |
||||
|
cp := *r |
||||
|
cp.Id = 0 |
||||
|
cp.AppName = app |
||||
|
if err := postgres.Insert(comm.TableSvcRegionOverride, &cp); err != nil { |
||||
|
return 0, fmt.Errorf("拷贝 %s@%d → %s: %w", r.SvcId, r.Region, app, err) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
for _, r := range rows { |
||||
|
exist := &SvcRegionOverride{} |
||||
|
if postgres.FindOne(comm.TableSvcRegionOverride, exist, "app_name=? AND svc_id=? AND region=?", first, r.SvcId, r.Region) == nil { |
||||
|
_ = postgres.Delete(comm.TableSvcRegionOverride, "id=?", r.Id) |
||||
|
continue |
||||
|
} |
||||
|
if err := postgres.Table(comm.TableSvcRegionOverride).Where("id=?", r.Id).Update("app_name", first).Error; err != nil { |
||||
|
return 0, err |
||||
|
} |
||||
|
} |
||||
|
return len(rows), nil |
||||
|
} |
||||
|
|
||||
|
func migrateScopeEchoOrch(first string, rest []string) (int, error) { |
||||
|
rows := make([]*EchoOrchestration, 0) |
||||
|
if err := postgres.Find(comm.TableEchomeetOrch, &rows, "app_name=?", ""); err != nil && err != postgres.ErrNoDocuments { |
||||
|
return 0, err |
||||
|
} |
||||
|
if len(rows) == 0 { |
||||
|
return 0, nil |
||||
|
} |
||||
|
for _, r := range rows { |
||||
|
for _, app := range rest { |
||||
|
exist := &EchoOrchestration{} |
||||
|
if err := postgres.FindOne(comm.TableEchomeetOrch, exist, |
||||
|
"app_name=? AND region=? AND kind=? AND svc_id=?", app, r.Region, r.Kind, r.SvcId); err == nil { |
||||
|
continue |
||||
|
} |
||||
|
cp := *r |
||||
|
cp.RowId = 0 |
||||
|
cp.AppName = app |
||||
|
if err := postgres.Insert(comm.TableEchomeetOrch, &cp); err != nil { |
||||
|
return 0, err |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
for _, r := range rows { |
||||
|
exist := &EchoOrchestration{} |
||||
|
if postgres.FindOne(comm.TableEchomeetOrch, exist, "app_name=? AND region=? AND kind=? AND svc_id=?", first, r.Region, r.Kind, r.SvcId) == nil { |
||||
|
_ = postgres.Delete(comm.TableEchomeetOrch, "row_id=?", r.RowId) |
||||
|
continue |
||||
|
} |
||||
|
if err := postgres.Table(comm.TableEchomeetOrch).Where("row_id=?", r.RowId).Update("app_name", first).Error; err != nil { |
||||
|
return 0, err |
||||
|
} |
||||
|
} |
||||
|
return len(rows), nil |
||||
|
} |
||||
|
|
||||
|
func migrateScopeEchoOrchSetting(first string, rest []string) (int, error) { |
||||
|
rows := make([]*EchoOrchSetting, 0) |
||||
|
if err := postgres.Find(comm.TableEchomeetOrchSetting, &rows, "app_name=?", ""); err != nil && err != postgres.ErrNoDocuments { |
||||
|
return 0, err |
||||
|
} |
||||
|
if len(rows) == 0 { |
||||
|
return 0, nil |
||||
|
} |
||||
|
for _, r := range rows { |
||||
|
for _, app := range rest { |
||||
|
exist := &EchoOrchSetting{} |
||||
|
if err := postgres.FindOne(comm.TableEchomeetOrchSetting, exist, |
||||
|
"app_name=? AND region=?", app, r.Region); err == nil { |
||||
|
continue |
||||
|
} |
||||
|
cp := *r |
||||
|
cp.RowId = 0 |
||||
|
cp.AppName = app |
||||
|
if err := postgres.Insert(comm.TableEchomeetOrchSetting, &cp); err != nil { |
||||
|
return 0, err |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
for _, r := range rows { |
||||
|
exist := &EchoOrchSetting{} |
||||
|
if postgres.FindOne(comm.TableEchomeetOrchSetting, exist, "app_name=? AND region=?", first, r.Region) == nil { |
||||
|
_ = postgres.Delete(comm.TableEchomeetOrchSetting, "row_id=?", r.RowId) |
||||
|
continue |
||||
|
} |
||||
|
if err := postgres.Table(comm.TableEchomeetOrchSetting).Where("row_id=?", r.RowId).Update("app_name", first).Error; err != nil { |
||||
|
return 0, err |
||||
|
} |
||||
|
} |
||||
|
return len(rows), nil |
||||
|
} |
||||
|
|
||||
|
// migrateScopeMeetTemplate 公共会议模板。表结构是 pb 生成的 struct(没有 AppName 字段),
|
||||
|
// app_name 列由 ensureConfigTables 用 ALTER 补上,这里走裸 SQL 按列名拷贝。
|
||||
|
// 首个应用同样原行改名——客户端记录里存的模板 id 得保住。
|
||||
|
func migrateScopeMeetTemplate(first string, rest []string) (int, error) { |
||||
|
pg := postgres.GetSys() |
||||
|
var n int64 |
||||
|
if err := pg.Raw("SELECT COUNT(*) FROM " + comm.TableEchomeetTemplate + |
||||
|
" WHERE source='public' AND COALESCE(app_name,'')=''").Scan(&n).Error; err != nil { |
||||
|
return 0, err |
||||
|
} |
||||
|
if n == 0 { |
||||
|
return 0, nil |
||||
|
} |
||||
|
cols := "tid, source, title, description, language, ttype, tags, icon, outline, template, sort" |
||||
|
for _, app := range rest { |
||||
|
// 已经有这个应用的同 tid+language 就跳过(NOT EXISTS),其余整批拷。
|
||||
|
if err := pg.Exec("INSERT INTO "+comm.TableEchomeetTemplate+" ("+cols+", app_name) "+ |
||||
|
"SELECT "+cols+", ? FROM "+comm.TableEchomeetTemplate+" g "+ |
||||
|
"WHERE g.source='public' AND COALESCE(g.app_name,'')='' AND NOT EXISTS ("+ |
||||
|
"SELECT 1 FROM "+comm.TableEchomeetTemplate+" x WHERE x.source='public' AND x.app_name=? AND x.tid=g.tid AND x.language=g.language)", |
||||
|
app, app).Error; err != nil { |
||||
|
return 0, err |
||||
|
} |
||||
|
} |
||||
|
// 首个应用:已有同 tid+language 的删全局行,其余改名。
|
||||
|
if err := pg.Exec("DELETE FROM "+comm.TableEchomeetTemplate+" g WHERE g.source='public' AND COALESCE(g.app_name,'')='' AND EXISTS ("+ |
||||
|
"SELECT 1 FROM "+comm.TableEchomeetTemplate+" x WHERE x.source='public' AND x.app_name=? AND x.tid=g.tid AND x.language=g.language)", first).Error; err != nil { |
||||
|
return 0, err |
||||
|
} |
||||
|
if err := pg.Exec("UPDATE "+comm.TableEchomeetTemplate+" SET app_name=? WHERE source='public' AND COALESCE(app_name,'')=''", first).Error; err != nil { |
||||
|
return 0, err |
||||
|
} |
||||
|
return int(n), nil |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
package console |
||||
|
|
||||
|
import "testing" |
||||
|
|
||||
|
// 作用域归一里「谁拿原行、谁拿拷贝」由这个函数决定;用错列(name 而不是 app_name)会让
|
||||
|
// 运行时按 ANALYZE_APP_NAME 一行都找不到,且不报错。
|
||||
|
func TestScopeTargetApps(t *testing.T) { |
||||
|
apps := []*AppRegistry{ |
||||
|
{Id: 17, Name: "deepglass", AppName: "deepGlass", Enabled: true}, |
||||
|
{Id: 15, Name: "EAIMAR-TEST", AppName: "EAIMAR-TEST", Enabled: true}, |
||||
|
{Id: 20, Name: "EAIMAR-HW", AppName: "EAIMAR-TEST", Enabled: true}, // 同一应用的第二个部署
|
||||
|
{Id: 3, Name: "old", AppName: "Old", Enabled: false}, // 停用的不参与
|
||||
|
{Id: 30, Name: "blank", AppName: " ", Enabled: true}, // 没填应用名的跳过
|
||||
|
} |
||||
|
got := scopeTargetApps(apps) |
||||
|
if len(got) != 2 || got[0] != "EAIMAR-TEST" || got[1] != "deepGlass" { |
||||
|
t.Fatalf("应按 app_registry.id 升序取去重后的 app_name,实际 %v", got) |
||||
|
} |
||||
|
// 首个(拿原行的)必须是 id 最小的已启用应用,而不是部署名字典序最小的
|
||||
|
if got[0] == "deepGlass" { |
||||
|
t.Fatal("首个应用应由 id 决定,不是名字") |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,74 @@ |
|||||
|
package console |
||||
|
|
||||
|
import ( |
||||
|
"yunyan/comm" |
||||
|
) |
||||
|
|
||||
|
// ============================ 第三方服务类别 (svc_category) ============================
|
||||
|
//
|
||||
|
// 2026-09-14 之前类别是三处写死的常量:admin 前端的 SCAT 数组(标签/颜色)、console 的 svcCat*
|
||||
|
// (模板与会议编排的类别映射)、comm 的 SvcCat*(真正带运行时语义的四个)。运营想加一个新类别
|
||||
|
// 或改个名字都得改代码发版。现在类别落库、后台可增删改,但要分清两种东西:
|
||||
|
//
|
||||
|
// - **展示属性**(label/short/color/bg/sort/enabled):任何类别都可改,纯后台分组用。
|
||||
|
// - **运行时语义**:只挂在 comm.SvcCat*(MT=3 用于 user_translate、AST=4 VIP 过期停发、
|
||||
|
// MCP=10 特殊字段与工具发现、IdVerify=11 服务端专用永不下发)以及模板/会议编排引用的
|
||||
|
// 内置 id(1/2/5/8/9)上。**这些 id 不能改也不能删**,改了运行时找不到;后台把它们标成 builtin。
|
||||
|
// 自定义类别(id ≥ 100)只有分组作用,不会因为叫「实名」就获得「不下发」的保护——
|
||||
|
// server_only 是从 comm 里读出来的只读属性,不落库、后台改不了,这是刻意的:
|
||||
|
// 它守的是主账号密钥不出网关,不该由一个勾选框决定。
|
||||
|
//
|
||||
|
// 删除自定义类别前会查 svc_config.categories 与 third_svc_template.category 的引用,
|
||||
|
// 有引用一律拒绝——否则那些服务在后台会掉进「未分类」,看着像丢了。
|
||||
|
|
||||
|
// SvcCategory 第三方服务类别(表 TableSvcCategory)。
|
||||
|
type SvcCategory struct { |
||||
|
Id int32 `gorm:"column:id;primaryKey" json:"id"` // 内置 1~12;自定义从 100 起由服务端分配
|
||||
|
Label string `gorm:"column:label;size:64" json:"label"` // 全称,如「STT 语音识别」
|
||||
|
Short string `gorm:"column:short;size:16" json:"short"` // 卡片角标,如「STT」
|
||||
|
Color string `gorm:"column:color;size:16" json:"color"` // 角标前景色
|
||||
|
Bg string `gorm:"column:bg;size:16" json:"bg"` // 角标背景色
|
||||
|
Sort int32 `gorm:"column:sort" json:"sort"` // 展示排序,越小越靠前
|
||||
|
Builtin bool `gorm:"column:builtin" json:"builtin"` // seed 内置:id 不可改、不可删(运行时/模板按 id 引用)
|
||||
|
Voice bool `gorm:"column:voice" json:"voice"` // 音频输出类:新建服务时按音色表预填 languages(原前端 VOICE_CATS)
|
||||
|
Enabled bool `gorm:"column:enabled" json:"enabled"` // 停用后不在新增向导里出现,已归入的服务照常展示
|
||||
|
|
||||
|
// ServerOnly 只读:来自 comm.IsServerOnlySvc,读列表时现算,不落库、后台改不了。
|
||||
|
ServerOnly bool `gorm:"-" json:"server_only"` |
||||
|
} |
||||
|
|
||||
|
func (SvcCategory) TableName() string { return comm.TableSvcCategory } |
||||
|
|
||||
|
// svcCategoryCustomIdStart 自定义类别 id 起点。1~99 留给内置(现在用到 12)。
|
||||
|
const svcCategoryCustomIdStart int32 = 100 |
||||
|
|
||||
|
// builtinSvcCategories 与 admin 原 SCAT 数组逐项对应(顺序即默认 sort),末尾是后加的「应用参数」。
|
||||
|
// ⚠️ 这里的 id 与 comm.SvcCat* / console svcCat* 是同一套数字,改任何一处都要三处同改。
|
||||
|
func builtinSvcCategories() []SvcCategory { |
||||
|
list := []SvcCategory{ |
||||
|
{Id: svcCatSTT, Label: "STT 语音识别", Short: "STT", Color: "#0369a1", Bg: "#dbeafe"}, |
||||
|
{Id: svcCatASRFile, Label: "录音文件识别", Short: "录音识别", Color: "#0e7490", Bg: "#cffafe"}, |
||||
|
{Id: svcCatTTS, Label: "TTS 语音合成", Short: "TTS", Color: "#7c3aed", Bg: "#ede9fe", Voice: true}, |
||||
|
{Id: svcCatMT, Label: "MT 机器翻译", Short: "MT", Color: "#059669", Bg: "#dcfce7"}, |
||||
|
{Id: svcCatAST, Label: "AST 端到端翻译", Short: "AST", Color: "#c2410c", Bg: "#fff7ed", Voice: true}, |
||||
|
{Id: svcCatLLM, Label: "LLM 文本大模型", Short: "文本LLM", Color: "#b45309", Bg: "#fef9c3"}, |
||||
|
{Id: svcCatLLMVision, Label: "多媒体大模型", Short: "多媒体LLM", Color: "#9333ea", Bg: "#f3e8ff"}, |
||||
|
{Id: svcCatSTS, Label: "STS 端到端对话", Short: "STS", Color: "#be185d", Bg: "#fce7f3", Voice: true}, |
||||
|
{Id: svcCatStorage, Label: "存储 OSS/COS", Short: "存储", Color: "#475569", Bg: "#f1f5f9"}, |
||||
|
{Id: svcCatMCP, Label: "MCP 服务", Short: "MCP", Color: "#0d9488", Bg: "#ccfbf1"}, |
||||
|
{Id: svcCatIdVerify, Label: "身份证校验", Short: "实名", Color: "#9f1239", Bg: "#ffe4e6"}, |
||||
|
// 应用参数(2026-09-14):非凭据的应用级业务参数,原「应用参数」标签页(业务库 config 表)下线后的唯一入口。
|
||||
|
{Id: svcCatAppParams, Label: "应用参数", Short: "参数", Color: "#334155", Bg: "#e2e8f0"}, |
||||
|
} |
||||
|
for i := range list { |
||||
|
list[i].Builtin = true |
||||
|
list[i].Enabled = true |
||||
|
list[i].Sort = int32(i) |
||||
|
} |
||||
|
return list |
||||
|
} |
||||
|
|
||||
|
// fillSvcCategoryRuntime 补上只读的运行时属性。
|
||||
|
func fillSvcCategoryRuntime(c *SvcCategory) { |
||||
|
c.ServerOnly = comm.IsServerOnlySvcCat(c.Id) |
||||
|
} |
||||
@ -0,0 +1,97 @@ |
|||||
|
package console |
||||
|
|
||||
|
import ( |
||||
|
"sort" |
||||
|
"strings" |
||||
|
|
||||
|
"yunyan/comm" |
||||
|
"yunyan/lego/sys/log" |
||||
|
"yunyan/lego/sys/postgres" |
||||
|
) |
||||
|
|
||||
|
// ============================ 作用域键一致性巡检(只读,只打日志) ============================
|
||||
|
//
|
||||
|
// 按应用隔离的配置(svc_config / 会议模板 …)用 app_name 当作用域键,而这个键有三个来源,
|
||||
|
// 三者必须是同一个字符串,错一个就会「后台改半天不生效」或「后台整页空白」,且**不报任何错**:
|
||||
|
//
|
||||
|
// ① 业务部署 .env 的 ANALYZE_APP_NAME —— 运行时 comm.AppName(),决定客户端读哪一套;
|
||||
|
// ② 应用注册表 app_registry.app_name —— 迁移/复制/后台下拉用它;
|
||||
|
// ③ 应用注册表 app_registry.name —— 统计落库认它(console.stat 的 getAppByName 查的是 name)。
|
||||
|
//
|
||||
|
// 线上真实踩过:测试机 app_registry 是 (name=EAIMAR-TEST, app_name=EAIMAR),而部署的
|
||||
|
// ANALYZE_APP_NAME=EAIMAR-TEST。于是配置全落在 'EAIMAR-TEST' 作用域(客户端读得到),
|
||||
|
// 后台下拉给出的却是 'EAIMAR'(app_name||name)→ 选它看到的是空页面,在那里新建的服务
|
||||
|
// 客户端一个都读不到。两边都不报错,只有对着库查才看得出来。
|
||||
|
//
|
||||
|
// 巡检不改数据(作用域键是运维决定的,自动改名可能把线上配置搬到没人读的地方),
|
||||
|
// 只把对不上的地方点名打出来。
|
||||
|
func checkScopeConsistency(apps []*AppRegistry) { |
||||
|
known := map[string]bool{} // 注册表认得的作用域键(app_name 与 name 都算)
|
||||
|
declared := map[string]bool{} // 后台下拉会给出的键(前端取 app_name||name,与它一致)
|
||||
|
for _, a := range apps { |
||||
|
if !a.Enabled { |
||||
|
continue |
||||
|
} |
||||
|
n, an := strings.TrimSpace(a.Name), strings.TrimSpace(a.AppName) |
||||
|
if n != "" { |
||||
|
known[n] = true |
||||
|
} |
||||
|
if an != "" { |
||||
|
known[an] = true |
||||
|
} |
||||
|
if an != "" && n != "" && an != n { |
||||
|
log.Warnf("console.scope: 应用注册表 id=%d 的 name(%q) 与 app_name(%q) 不同值——"+ |
||||
|
"配置作用域、后台下拉、统计落库三者会各认一个,务必改成同值(见 comm/appscope.go)", a.Id, n, an) |
||||
|
} |
||||
|
if d := an; d != "" { |
||||
|
declared[d] = true |
||||
|
} else if n != "" { |
||||
|
declared[n] = true |
||||
|
} |
||||
|
} |
||||
|
if len(known) == 0 { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
for _, table := range []string{comm.TableSvcConfig, comm.TableEchomeetTemplate} { |
||||
|
var scopes []string |
||||
|
if err := postgres.Table(table).Group("app_name").Pluck("app_name", &scopes).Error; err != nil { |
||||
|
log.Warnf("console.scope: 巡检 %s 的作用域键失败(跳过): %v", table, err) |
||||
|
continue |
||||
|
} |
||||
|
orphan := make([]string, 0) |
||||
|
for _, s := range scopes { |
||||
|
s = strings.TrimSpace(s) |
||||
|
if s == "" || known[s] { |
||||
|
continue |
||||
|
} |
||||
|
orphan = append(orphan, s) |
||||
|
} |
||||
|
if len(orphan) > 0 { |
||||
|
sort.Strings(orphan) |
||||
|
log.Warnf("console.scope: %s 里有配置挂在注册表不认识的作用域 %v ——"+ |
||||
|
"若某个业务部署的 ANALYZE_APP_NAME 正是它,那套配置正在生效却在后台**看不到也改不了**;"+ |
||||
|
"对齐办法是把 app_registry 的 name/app_name 都改成这个值(不要改业务部署的 .env,"+ |
||||
|
"那会让客户端立刻读到另一套配置)", table, orphan) |
||||
|
} |
||||
|
// 反向:后台下拉会给出、但库里一行配置都没有的作用域——选中它就是空白页。
|
||||
|
empty := make([]string, 0) |
||||
|
for d := range declared { |
||||
|
found := false |
||||
|
for _, s := range scopes { |
||||
|
if strings.TrimSpace(s) == d { |
||||
|
found = true |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
if !found { |
||||
|
empty = append(empty, d) |
||||
|
} |
||||
|
} |
||||
|
if len(empty) > 0 && len(orphan) > 0 { |
||||
|
sort.Strings(empty) |
||||
|
log.Warnf("console.scope: %s 里作用域 %v 一行都没有,而后台下拉正会给出它们——"+ |
||||
|
"配合上面那条孤儿作用域看,多半是同一个应用的两个名字", table, empty) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,33 +0,0 @@ |
|||||
package user |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/comm" |
|
||||
"yunyan/pb" |
|
||||
) |
|
||||
|
|
||||
// @Summary 获取唤醒词列表
|
|
||||
// @Description 获取唤醒词列表
|
|
||||
// @Tags User
|
|
||||
// @Accept json
|
|
||||
// @Produce json
|
|
||||
// @Security BearerAuth
|
|
||||
// @Param user body pb.UserGetWakeupVoicesReq true "用户反馈"
|
|
||||
// @Success 200 {object} comm.HttpResult{data=pb.UserGetWakeupVoicesResp} "成功返回"
|
|
||||
// @Router /api/home/user_getwakeupvoices [post]
|
|
||||
func (this *apiComp) GetWakeupVoices(session comm.IUserSession, req *pb.UserGetWakeupVoicesReq) (resp *pb.UserGetWakeupVoicesResp, errdata *pb.ErrorData) { |
|
||||
var ( |
|
||||
voices []*pb.DBWakeupVoice |
|
||||
err error |
|
||||
) |
|
||||
if voices, err = this.module.configmodel.getWakeupVoices(); err != nil { |
|
||||
errdata = &pb.ErrorData{ |
|
||||
Code: pb.ErrorCode_DBError, |
|
||||
Message: err.Error(), |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
resp = &pb.UserGetWakeupVoicesResp{ |
|
||||
Voices: voices, |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
@ -1,70 +0,0 @@ |
|||||
package firebase_auth |
|
||||
|
|
||||
import ( |
|
||||
"context" |
|
||||
"fmt" |
|
||||
|
|
||||
firebase "firebase.google.com/go/v4" |
|
||||
"firebase.google.com/go/v4/auth" |
|
||||
"github.com/coze-dev/coze-go" |
|
||||
"google.golang.org/api/option" |
|
||||
) |
|
||||
|
|
||||
// newSys 创建 Firebase 认证系统实例并初始化 Auth 客户端。
|
|
||||
// 参数:
|
|
||||
// - options: 运行所需配置(如服务账号密钥路径、日志)
|
|
||||
//
|
|
||||
// 返回值:
|
|
||||
// - sys: 创建成功的实例
|
|
||||
// - err: 创建或初始化失败时返回错误
|
|
||||
func newSys(options Options) (sys *Google, err error) { |
|
||||
sys = &Google{ |
|
||||
options: options, |
|
||||
} |
|
||||
// 指定服务账号密钥路径
|
|
||||
opt := option.WithCredentialsFile(options.ApiKeyFile) |
|
||||
// 初始化 Firebase App
|
|
||||
app, err := firebase.NewApp(context.Background(), nil, opt) |
|
||||
if err != nil { |
|
||||
options.Log.Errorln(err) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 获取 Auth 客户端
|
|
||||
sys.client, err = app.Auth(context.Background()) |
|
||||
if err != nil { |
|
||||
options.Log.Errorln(err) |
|
||||
return |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
type Google struct { |
|
||||
options Options |
|
||||
api coze.CozeAPI |
|
||||
client *auth.Client |
|
||||
} |
|
||||
|
|
||||
// Auth 使用 Firebase Admin SDK 校验 ID Token。
|
|
||||
// 参数:
|
|
||||
// - ctx: 上下文
|
|
||||
// - idToken: 客户端传入的 Firebase ID Token
|
|
||||
//
|
|
||||
// 返回值:
|
|
||||
// - info: 校验成功后返回的 Token 信息
|
|
||||
// - err: 校验失败或邮箱未验证时返回错误
|
|
||||
func (this *Google) Auth(ctx context.Context, idToken string) (info *auth.Token, err error) { |
|
||||
// 验证 Token
|
|
||||
token, err := this.client.VerifyIDToken(ctx, idToken) |
|
||||
if err != nil { |
|
||||
return nil, fmt.Errorf("invalid ID Token: %v", err) |
|
||||
} |
|
||||
|
|
||||
// 检查邮箱是否已验证(仅当 email_verified 明确为 false 时拒绝;缺失/非 bool 不再导致 panic)
|
|
||||
// if v, ok := token.Claims["email_verified"]; ok && v != nil {
|
|
||||
// if verified, ok := v.(bool); ok && !verified {
|
|
||||
// return nil, fmt.Errorf("email not verified")
|
|
||||
// }
|
|
||||
// }
|
|
||||
return token, nil |
|
||||
} |
|
||||
@ -1,41 +0,0 @@ |
|||||
package firebase_auth |
|
||||
|
|
||||
import ( |
|
||||
"context" |
|
||||
"errors" |
|
||||
|
|
||||
"firebase.google.com/go/v4/auth" |
|
||||
) |
|
||||
|
|
||||
type ( |
|
||||
ISys interface { |
|
||||
Auth(ctx context.Context, idToken string) (info *auth.Token, err error) |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
var defsys ISys |
|
||||
|
|
||||
// ErrNotInited Firebase 登录未初始化。OnInit 失败时调用方可选择降级;此时包级函数返回本错误,而不是 panic。
|
|
||||
var ErrNotInited = errors.New("firebase_auth 未初始化:配置缺失") |
|
||||
|
|
||||
func OnInit(config map[string]interface{}, option ...Option) error { |
|
||||
sys, err := newSys(newOptions(config, option...)) |
|
||||
if err != nil { |
|
||||
// 出错时不要给 defsys 赋值,避免「非 nil 接口 + nil 指针」让守卫失效。
|
|
||||
return err |
|
||||
} |
|
||||
defsys = sys |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
func NewSys(option ...Option) (sys ISys, err error) { |
|
||||
sys, err = newSys(newOptionsByOption(option...)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
func Auth(ctx context.Context, idToken string) (info *auth.Token, err error) { |
|
||||
if defsys == nil { |
|
||||
return nil, ErrNotInited |
|
||||
} |
|
||||
return defsys.Auth(ctx, idToken) |
|
||||
} |
|
||||
@ -1,44 +0,0 @@ |
|||||
package firebase_auth |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/lego/sys/log" |
|
||||
"yunyan/lego/utils/mapstructure" |
|
||||
) |
|
||||
|
|
||||
type Option func(*Options) |
|
||||
type Options struct { |
|
||||
Debug bool //日志是否开启
|
|
||||
Log log.ILogger |
|
||||
ApiKeyFile string |
|
||||
} |
|
||||
|
|
||||
func SetApiKeyFile(v string) Option { |
|
||||
return func(o *Options) { |
|
||||
o.ApiKeyFile = v |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
func newOptions(config map[string]interface{}, opts ...Option) Options { |
|
||||
options := Options{} |
|
||||
if config != nil { |
|
||||
mapstructure.Decode(config, &options) |
|
||||
} |
|
||||
for _, o := range opts { |
|
||||
o(&options) |
|
||||
} |
|
||||
if options.Log == nil { |
|
||||
options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.tavily", 3)) |
|
||||
} |
|
||||
return options |
|
||||
} |
|
||||
|
|
||||
func newOptionsByOption(opts ...Option) Options { |
|
||||
options := Options{} |
|
||||
for _, o := range opts { |
|
||||
o(&options) |
|
||||
} |
|
||||
if options.Log == nil { |
|
||||
options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.tavily", 3)) |
|
||||
} |
|
||||
return options |
|
||||
} |
|
||||
@ -1,20 +0,0 @@ |
|||||
package firebase_auth_test |
|
||||
|
|
||||
import ( |
|
||||
|
|
||||
//"lego_bighealth/sys/coze"
|
|
||||
|
|
||||
"context" |
|
||||
firebase_auth "yunyan/sys/auth/firebase" |
|
||||
"fmt" |
|
||||
"testing" |
|
||||
) |
|
||||
|
|
||||
func Test_Sys_Chat(t *testing.T) { |
|
||||
if sys, err := firebase_auth.NewSys(); err != nil { |
|
||||
fmt.Printf("Sys Init err:%v", err) |
|
||||
} else { |
|
||||
info, err := sys.Auth(context.Background(), "eyJhbGciOiJSUzI1NiIsImtpZCI6IjMwYjIyMWFiNjU2MTdiY2Y4N2VlMGY4NDYyZjc0ZTM2NTIyY2EyZTQiLCJ0eXAiOiJKV1QifQ.eyJuYW1lIjoiY2h1YW5neGluIHl1bnFpIiwicGljdHVyZSI6Imh0dHBzOi8vbGgzLmdvb2dsZXVzZXJjb250ZW50LmNvbS9hL0FDZzhvY0p3OTZwSHFvM3VjZXl4eXJXemlRcjhlR1MtQ1paX05oUkJOQ19Fdm1Ha0F5ZjZUUT1zOTYtYyIsImlzcyI6Imh0dHBzOi8vc2VjdXJldG9rZW4uZ29vZ2xlLmNvbS9kZWVwc291bmQtZTBlM2QiLCJhdWQiOiJkZWVwc291bmQtZTBlM2QiLCJhdXRoX3RpbWUiOjE3NDI2MTA5MjMsInVzZXJfaWQiOiJVeXJrbTJBTDdEWnJuSHpmTmh6cTk4NWxRdjEyIiwic3ViIjoiVXlya20yQUw3RFpybkh6Zk5oenE5ODVsUXYxMiIsImlhdCI6MTc0MjYxMDkyMywiZXhwIjoxNzQyNjE0NTIzLCJlbWFpbCI6Inl1bnFpaW5ub3ZhdGlvbkBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZmlyZWJhc2UiOnsiaWRlbnRpdGllcyI6eyJnb29nbGUuY29tIjpbIjEwNjgyMjc5MDg3MTQ0MTY4NDkxMSJdLCJlbWFpbCI6WyJ5dW5xaWlubm92YXRpb25AZ21haWwuY29tIl19LCJzaWduX2luX3Byb3ZpZGVyIjoiZ29vZ2xlLmNvbSJ9fQ.rw6DkZUM97GypdB91FMpn4UY1PvMqXegvH5u34R4zVjpF55OmVOJEI2TNl6j1K9yzL4Kq4p5_vrLLfhBqFOjkc8UfX98xKZT5oLMrfkNrlR_W8omXJ0W3HAUEaG4KGLGSJn-hiQwgp2ZEBl7zvbWaapjlpVvDhfhV5AZMaHXUalxV9iure8ubo-36F0NY3YM63YQb9QPXJ9KYIzc413mUu5Ee0oaHt_jl6Jc0HZbW7Q3qvmNFh608n79cgkToMNPJTDYLCpSOblRucJEXP7OfW7vanUOHjJyPA1EI-w_kchss664OESjnn-YGw6igkKcRDAbIZk1HEL7o3hoBfF4og") |
|
||||
fmt.Printf("Sys info:%+v err:%v", info, err) |
|
||||
} |
|
||||
} |
|
||||
Loading…
Reference in new issue