feat: 配置中心-凭据(邮件推送AI表单/掩码交互/SMTP测试/推送探测端点)

- 后端 POST /tenants/{slug}/email/test:resolve_email_settings 当前配置
  真发测试邮件(主题 [all-kefu] SMTP 测试邮件+租户名+UTC+8 时间);
  回退后 host/user 仍空 → 400;成败如实返回 {sent, error},密钥不进响应
- 后端 POST /tenants/{slug}/push/test:连通性探测——与 _do_post 同款
  连接形态 POST 空 messages 批量(红线:绝不携带线索数据);不可达
  reason 分类 dns/timeout/connect(Windows 裸 ConnectError 文案兜底),
  auth_ok 三态(2xx 真 / 401·403 假 / 其余 null),单次无重试
- email_service 抽 _smtp_send 共用连接逻辑(_send 契约不变)+
  send_test_email(成败+错误串如实返回,不走吞异常 bool)
- 前端 CredsPane:邮件/推送/AI 三组凭据表单(数值/邮箱前端预检 +
  后端 400 双层);密钥哨兵交互(__SET__ 占位符/不动保存保持库值/
  清除按钮显式清空可撤销);SMTP 测试与推送探测三态内联展示
- 测试:pytest 新增 18(email/test 7 + push/test 11),全量 595 通过;
  vitest 新增 20(model 哨兵往返/预检边界 + API 调用形态),107 通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 20:55:28 +08:00
parent 39d72c53aa
commit bf923d9cf7
11 changed files with 2024 additions and 19 deletions
+743
View File
@@ -0,0 +1,743 @@
<!--
凭据三组编辑(Phase 3 Task 7c;消费 config/center/model.ts 纯逻辑)
- 邮件(email 组):SMTP 四字段 + 发件人名称 + 通知邮箱数组;数值/邮箱
格式前端预检(端口 1-65535 / 项含 @),后端 400 二层兜底
- 推送(push 组):开关 + URL + API Key + 前缀 + 三个数值(正数 / 1-10
整数,留空 = null = 平台默认);prefix 跨租户冲突的 400 detail 内联展示
- AI(ai 组):provider / API Key / base_url / model
- 密钥哨兵交互(mask.ts):GET 密钥为 '__SET__' → 输入框留空 + 占位符
「已设置,输入新值覆盖」;不动保存回传哨兵保持库值;「清除」按钮 =
显式清空意图(提交空串),可再点「恢复保持」撤销
- SMTP 测试邮件:收件人 + 按钮 → POST email/test——用**已保存**配置真发
一封(先保存再测试);未配置 400 / 发送失败 sent=false 均内联展示
- 推送连通性探测:POST push/test(后端发空 messages 批量,零线索数据)
——三态展示:不可达(dns/timeout/connect 原因分类)/ 可达但鉴权失败
(401·403)/ 正常(2xx);其余状态码 = 可达但鉴权不定
-->
<template>
<div class="creds-pane">
<!-- 邮件 -->
<ElCard shadow="never" class="form-card">
<template #header>
<div class="card-header">
<span class="card-title">邮件(email 组 · SMTP 发信)</span>
<span class="card-sub">空槽 = 未设置 → 回退平台环境默认;保存即写新版本并热生效</span>
</div>
</template>
<ElAlert
v-if="emailError"
type="error"
:closable="true"
:title="emailError"
class="pane-error"
@close="emailError = ''"
/>
<ElForm label-width="130px" @submit.prevent>
<ElFormItem label="SMTP 服务器">
<ElInput
v-model="emailForm.smtpHost"
placeholder="如 smtp.exmail.qq.com(留空 = 平台默认)"
/>
</ElFormItem>
<ElFormItem label="SMTP 端口">
<ElInput
v-model="emailForm.smtpPort"
placeholder="如 465(1-65535 整数,留空 = 平台默认)"
/>
</ElFormItem>
<ElFormItem label="SMTP 账号">
<ElInput
v-model="emailForm.smtpUser"
placeholder="发件邮箱账号(留空 = 平台默认)"
/>
</ElFormItem>
<ElFormItem label="SMTP 密码">
<div class="secret-row">
<ElInput
v-model="emailForm.smtpPassword"
type="password"
show-password
autocomplete="new-password"
:placeholder="emailPasswordPlaceholder"
class="secret-input"
/>
<ElButton
v-if="emailPasswordMasked === MASKED_SENTINEL"
size="small"
:type="emailPasswordCleared ? 'default' : 'danger'"
plain
@click="emailPasswordCleared = !emailPasswordCleared"
>
{{ emailPasswordCleared ? '恢复保持' : '清除' }}
</ElButton>
</div>
<p v-if="emailPasswordCleared" class="field-warn">
保存后将清除已存密码(空槽回退平台默认)
</p>
</ElFormItem>
<ElFormItem label="发件人名称">
<ElInput
v-model="emailForm.senderName"
placeholder='邮件【发件人名称】展示(留空 = "HESUPIPE")'
/>
</ElFormItem>
<ElFormItem label="通知邮箱">
<ElSelect
v-model="emailForm.notifyEmails"
multiple
filterable
allow-create
default-first-option
:reserve-keyword="false"
placeholder="输入邮箱后回车添加(新询盘通知收件人,留空 = 平台默认)"
class="wide-control"
/>
</ElFormItem>
<ElFormItem label="保存备注">
<ElInput
v-model="emailComment"
maxlength="200"
placeholder="可选;写入版本历史 comment"
/>
</ElFormItem>
</ElForm>
<div class="save-bar">
<ElButton type="primary" :loading="emailSaving" @click="onSaveEmail">
保存邮件组(整组提交,热生效)
</ElButton>
</div>
<ElDivider class="test-divider" align="left">连通性验证(用已保存配置)</ElDivider>
<div class="test-row">
<ElInput
v-model="emailTestTo"
placeholder="收件人邮箱(如 you@example.com)"
class="test-input"
/>
<ElButton :loading="emailTesting" :disabled="!slug" @click="onTestEmail">
发测试邮件
</ElButton>
</div>
<p class="test-hint">
后端用**已保存**的邮件配置真发一封(主题「[all-kefu] SMTP 测试邮件」)
——改完先保存再测试;SMTP 未配置时返回 400。
</p>
<ElAlert
v-if="emailTestResult"
:type="emailTestResult.type"
:closable="true"
:title="emailTestResult.text"
:description="emailTestResult.detail || undefined"
class="test-result"
@close="emailTestResult = null"
/>
</ElCard>
<!-- 推送 -->
<ElCard shadow="never" class="form-card">
<template #header>
<div class="card-header">
<span class="card-title">推送(push 组 · 线索推送 UniService)</span>
<span class="card-sub">数值留空 = 平台默认;前缀全局唯一(跨租户查重)</span>
</div>
</template>
<ElAlert
v-if="pushError"
type="error"
:closable="true"
:title="pushError"
class="pane-error"
@close="pushError = ''"
/>
<ElForm label-width="130px" @submit.prevent>
<ElFormItem label="启用推送">
<ElSwitch v-model="pushForm.enabled" />
<span class="switch-hint">关闭后新线索不推送(只落库)</span>
</ElFormItem>
<ElFormItem label="推送 URL">
<ElInput
v-model="pushForm.url"
placeholder="https://push.example/open/v1/inquiry/push(留空 = 平台默认)"
/>
</ElFormItem>
<ElFormItem label="API Key">
<div class="secret-row">
<ElInput
v-model="pushForm.apiKey"
type="password"
show-password
autocomplete="new-password"
:placeholder="pushApiKeyPlaceholder"
class="secret-input"
/>
<ElButton
v-if="pushApiKeyMasked === MASKED_SENTINEL"
size="small"
:type="pushApiKeyCleared ? 'default' : 'danger'"
plain
@click="pushApiKeyCleared = !pushApiKeyCleared"
>
{{ pushApiKeyCleared ? '恢复保持' : '清除' }}
</ElButton>
</div>
<p v-if="pushApiKeyCleared" class="field-warn">
保存后将清除已存 API Key(空槽回退平台默认)
</p>
</ElFormItem>
<ElFormItem label="线索前缀">
<ElInput
v-model="pushForm.prefix"
placeholder="external_key 前缀(全局唯一;留空 = 平台默认)"
/>
</ElFormItem>
<ElFormItem label="超时(秒)">
<ElInput
v-model="pushForm.timeout"
placeholder="正数,如 10(留空 = 平台默认)"
/>
</ElFormItem>
<ElFormItem label="最大尝试次数">
<ElInput
v-model="pushForm.maxAttempts"
placeholder="1-10 整数,如 3(留空 = 平台默认)"
/>
</ElFormItem>
<ElFormItem label="退避基数(秒)">
<ElInput
v-model="pushForm.backoffBase"
placeholder="正数,如 1(留空 = 平台默认)"
/>
</ElFormItem>
<ElFormItem label="保存备注">
<ElInput
v-model="pushComment"
maxlength="200"
placeholder="可选;写入版本历史 comment"
/>
</ElFormItem>
</ElForm>
<div class="save-bar">
<ElButton type="primary" :loading="pushSaving" @click="onSavePush">
保存推送组(整组提交,热生效)
</ElButton>
</div>
<ElDivider class="test-divider" align="left">连通性探测(用已保存配置)</ElDivider>
<div class="test-row">
<ElButton :loading="pushProbing" :disabled="!slug" @click="onProbePush">
探测推送端点
</ElButton>
<span class="test-hint-inline">
只验可达 + 鉴权,<b>不发送任何线索数据</b>(后端发空批量)
</span>
</div>
<p class="test-hint">改完 URL / API Key 先保存再探测——探测读的是已保存配置。</p>
<ElAlert
v-if="pushProbeError"
type="error"
:closable="true"
:title="pushProbeError"
class="test-result"
@close="pushProbeError = ''"
/>
<ElAlert
v-else-if="pushProbe && pushProbe.reachable && pushProbe.auth_ok === true"
type="success"
:closable="true"
class="test-result"
:title="`正常:端点可达且鉴权通过(HTTP ${pushProbe.status})`"
@close="pushProbe = null"
/>
<ElAlert
v-else-if="pushProbe && pushProbe.reachable && pushProbe.auth_ok === false"
type="error"
:closable="true"
class="test-result"
:title="`可达但鉴权失败(HTTP ${pushProbe.status})——检查 API Key`"
@close="pushProbe = null"
/>
<ElAlert
v-else-if="pushProbe && pushProbe.reachable"
type="warning"
:closable="true"
class="test-result"
:title="`可达但状态异常(HTTP ${pushProbe.status})——鉴权不定,请人工确认`"
@close="pushProbe = null"
/>
<ElAlert
v-else-if="pushProbe"
type="error"
:closable="true"
class="test-result"
:title="`不可达:${probeReasonLabel}`"
:description="pushProbe.error || undefined"
@close="pushProbe = null"
/>
</ElCard>
<!-- AI -->
<ElCard shadow="never" class="form-card">
<template #header>
<div class="card-header">
<span class="card-title">AI(ai 组 · 模型服务)</span>
<span class="card-sub">密钥空槽 = 未设置 → 回退平台集团凭据(spec §5.5)</span>
</div>
</template>
<ElAlert
v-if="aiError"
type="error"
:closable="true"
:title="aiError"
class="pane-error"
@close="aiError = ''"
/>
<ElForm label-width="130px" @submit.prevent>
<ElFormItem label="Provider">
<ElInput
v-model="aiForm.provider"
placeholder="如 glm(留空 = 平台默认)"
/>
</ElFormItem>
<ElFormItem label="API Key">
<div class="secret-row">
<ElInput
v-model="aiForm.apiKey"
type="password"
show-password
autocomplete="new-password"
:placeholder="aiApiKeyPlaceholder"
class="secret-input"
/>
<ElButton
v-if="aiApiKeyMasked === MASKED_SENTINEL"
size="small"
:type="aiApiKeyCleared ? 'default' : 'danger'"
plain
@click="aiApiKeyCleared = !aiApiKeyCleared"
>
{{ aiApiKeyCleared ? '恢复保持' : '清除' }}
</ElButton>
</div>
<p v-if="aiApiKeyCleared" class="field-warn">
保存后将清除已存 API Key(空槽回退平台集团凭据)
</p>
</ElFormItem>
<ElFormItem label="Base URL">
<ElInput
v-model="aiForm.baseUrl"
placeholder="如 https://open.bigmodel.cn/api/paas/v4(留空 = 平台默认)"
/>
</ElFormItem>
<ElFormItem label="模型">
<ElInput
v-model="aiForm.model"
placeholder="如 glm-4-flash(留空 = 平台默认)"
/>
</ElFormItem>
<ElFormItem label="保存备注">
<ElInput
v-model="aiComment"
maxlength="200"
placeholder="可选;写入版本历史 comment"
/>
</ElFormItem>
</ElForm>
<div class="save-bar">
<ElButton type="primary" :loading="aiSaving" @click="onSaveAi">
保存 AI 组(整组提交,热生效)
</ElButton>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import {
PUSH_PROBE_REASON_LABELS,
buildAiCredsPayload,
buildEmailCredsPayload,
buildPushCredsPayload,
normalizeAiCredsGroup,
normalizeEmailCredsGroup,
normalizePushCredsGroup,
validateEmailCredsForm,
validatePushCredsForm,
type AiCredsForm,
type EmailCredsForm,
type PushCredsForm
} from './model'
import { putConfigGroup, testEmail, testPush, type PushTestResponse } from '@/api/config'
import { MASKED_SENTINEL, secretPlaceholder } from '@/api/mask'
defineOptions({ name: 'CredsPane' })
const props = defineProps<{
/** 租户 slug(保存/测试目标) */
slug: string
/** GET config 回显的 email 组(原样快照;null = 尚未加载) */
emailGroup: Record<string, unknown> | null | undefined
/** GET config 回显的 push 组 */
pushGroup: Record<string, unknown> | null | undefined
/** GET config 回显的 ai 组 */
aiGroup: Record<string, unknown> | null | undefined
}>()
const emit = defineEmits<{
/** 保存成功:父层更新版本号与快照组 */
(
e: 'saved',
payload: {
version: number
group: 'email' | 'push' | 'ai'
config: Record<string, unknown>
}
): void
}>()
// ---- 编辑态(组引用变化 = 保存回写/租户切换/重载 → 重置表单与密钥态) ----------------
const emailForm = reactive<EmailCredsForm>(normalizeEmailCredsGroup(undefined).form)
const pushForm = reactive<PushCredsForm>(normalizePushCredsGroup(undefined).form)
const aiForm = reactive<AiCredsForm>(normalizeAiCredsGroup(undefined).form)
/** 密钥掩码状态(GET 原样:哨兵或空)+ 显式清除意图(提交空串,可撤销) */
const emailPasswordMasked = ref('')
const emailPasswordCleared = ref(false)
const pushApiKeyMasked = ref('')
const pushApiKeyCleared = ref(false)
const aiApiKeyMasked = ref('')
const aiApiKeyCleared = ref(false)
const emailComment = ref('')
const pushComment = ref('')
const aiComment = ref('')
const emailSaving = ref(false)
const pushSaving = ref(false)
const aiSaving = ref(false)
const emailError = ref('')
const pushError = ref('')
const aiError = ref('')
// 测试态(声明先于下方 immediate watch 的 resetAll——避免 TDZ 引用)
const emailTestTo = ref('')
const emailTesting = ref(false)
const emailTestResult = ref<{
type: 'success' | 'error'
text: string
detail?: string
} | null>(null)
const pushProbing = ref(false)
const pushProbe = ref<PushTestResponse | null>(null)
const pushProbeError = ref('')
function resetAll(
emailGroup: Record<string, unknown> | null | undefined,
pushGroup: Record<string, unknown> | null | undefined,
aiGroup: Record<string, unknown> | null | undefined
): void {
const es = normalizeEmailCredsGroup(emailGroup)
Object.assign(emailForm, es.form)
emailPasswordMasked.value = es.passwordMasked
emailPasswordCleared.value = false
const ps = normalizePushCredsGroup(pushGroup)
Object.assign(pushForm, ps.form)
pushApiKeyMasked.value = ps.apiKeyMasked
pushApiKeyCleared.value = false
const as = normalizeAiCredsGroup(aiGroup)
Object.assign(aiForm, as.form)
aiApiKeyMasked.value = as.apiKeyMasked
aiApiKeyCleared.value = false
emailComment.value = ''
pushComment.value = ''
aiComment.value = ''
emailError.value = ''
pushError.value = ''
aiError.value = ''
emailTestResult.value = null
emailTesting.value = false
pushProbe.value = null
pushProbeError.value = ''
pushProbing.value = false
}
watch(
() => [props.emailGroup, props.pushGroup, props.aiGroup],
([emailGroup, pushGroup, aiGroup]) => resetAll(emailGroup, pushGroup, aiGroup),
{ immediate: true }
)
const emailPasswordPlaceholder = computed(() =>
emailPasswordCleared.value
? secretPlaceholder('')
: secretPlaceholder(emailPasswordMasked.value)
)
const pushApiKeyPlaceholder = computed(() =>
pushApiKeyCleared.value
? secretPlaceholder('')
: secretPlaceholder(pushApiKeyMasked.value)
)
const aiApiKeyPlaceholder = computed(() =>
aiApiKeyCleared.value ? secretPlaceholder('') : secretPlaceholder(aiApiKeyMasked.value)
)
// ---- 保存(前端预检 → 整组 PUT;密钥不动回哨兵保持库值) ------------------------------
async function onSaveEmail(): Promise<void> {
if (emailSaving.value) return
emailError.value = ''
const errors = validateEmailCredsForm(emailForm)
if (errors.length > 0) {
emailError.value = errors.join(';')
return
}
emailSaving.value = true
try {
const res = await putConfigGroup(
props.slug,
'email',
buildEmailCredsPayload(
emailForm,
emailPasswordCleared.value ? '' : emailPasswordMasked.value
),
emailComment.value.trim() || undefined,
{ showErrorMessage: false }
)
const state = normalizeEmailCredsGroup(res.config)
Object.assign(emailForm, state.form)
emailPasswordMasked.value = state.passwordMasked
emailPasswordCleared.value = false
emailComment.value = ''
ElMessage.success(`邮件配置已保存(版本 v${res.version},热生效)`)
emit('saved', { version: res.version, group: 'email', config: res.config })
} catch (error) {
// 400 校验 detail(带字段路径,如 "email.notify_emails[0]: …")内联展示
emailError.value = error instanceof Error ? error.message : '保存失败'
} finally {
emailSaving.value = false
}
}
async function onSavePush(): Promise<void> {
if (pushSaving.value) return
pushError.value = ''
const errors = validatePushCredsForm(pushForm)
if (errors.length > 0) {
pushError.value = errors.join(';')
return
}
pushSaving.value = true
try {
const res = await putConfigGroup(
props.slug,
'push',
buildPushCredsPayload(
pushForm,
pushApiKeyCleared.value ? '' : pushApiKeyMasked.value
),
pushComment.value.trim() || undefined,
{ showErrorMessage: false }
)
const state = normalizePushCredsGroup(res.config)
Object.assign(pushForm, state.form)
pushApiKeyMasked.value = state.apiKeyMasked
pushApiKeyCleared.value = false
pushComment.value = ''
ElMessage.success(`推送配置已保存(版本 v${res.version},热生效)`)
emit('saved', { version: res.version, group: 'push', config: res.config })
} catch (error) {
// 含 prefix 跨租户占用:"push.prefix: 'xxx' 已被其他租户占用…"
pushError.value = error instanceof Error ? error.message : '保存失败'
} finally {
pushSaving.value = false
}
}
async function onSaveAi(): Promise<void> {
if (aiSaving.value) return
aiError.value = ''
aiSaving.value = true
try {
const res = await putConfigGroup(
props.slug,
'ai',
buildAiCredsPayload(aiForm, aiApiKeyCleared.value ? '' : aiApiKeyMasked.value),
aiComment.value.trim() || undefined,
{ showErrorMessage: false }
)
const state = normalizeAiCredsGroup(res.config)
Object.assign(aiForm, state.form)
aiApiKeyMasked.value = state.apiKeyMasked
aiApiKeyCleared.value = false
aiComment.value = ''
ElMessage.success(`AI 配置已保存(版本 v${res.version},热生效)`)
emit('saved', { version: res.version, group: 'ai', config: res.config })
} catch (error) {
aiError.value = error instanceof Error ? error.message : '保存失败'
} finally {
aiSaving.value = false
}
}
// ---- SMTP 测试邮件(email/test;用已保存配置真发一封) --------------------------------
async function onTestEmail(): Promise<void> {
if (emailTesting.value) return
const to = emailTestTo.value.trim()
if (!to || !to.includes('@')) {
emailTestResult.value = { type: 'error', text: '请先填写含 @ 的收件人邮箱' }
return
}
emailTesting.value = true
emailTestResult.value = null
try {
const res = await testEmail(props.slug, { to }, { showErrorMessage: false })
if (res.sent) {
emailTestResult.value = {
type: 'success',
text: `测试邮件已发出(${to}),请查收验证配置`
}
} else {
emailTestResult.value = {
type: 'error',
text: '发送失败——SMTP 配置可能不正确',
detail: res.error ?? ''
}
}
} catch (error) {
// 400「SMTP 未配置」等后端预检拒绝(ApiError.message = detail)
emailTestResult.value = {
type: 'error',
text: error instanceof Error ? error.message : '测试发送失败'
}
} finally {
emailTesting.value = false
}
}
// ---- 推送连通性探测(push/test;空批量,零线索数据) ----------------------------------
const probeReasonLabel = computed(
() =>
PUSH_PROBE_REASON_LABELS[pushProbe.value?.reason ?? ''] ??
'连接失败(未分类原因)'
)
async function onProbePush(): Promise<void> {
if (pushProbing.value) return
pushProbing.value = true
pushProbe.value = null
pushProbeError.value = ''
try {
pushProbe.value = await testPush(props.slug, { showErrorMessage: false })
} catch (error) {
// 400「推送 URL 未配置」等后端预检拒绝
pushProbeError.value = error instanceof Error ? error.message : '探测失败'
} finally {
pushProbing.value = false
}
}
</script>
<style scoped lang="scss">
.creds-pane {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-width: 0;
width: 100%;
}
.card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
.card-title {
font-weight: 600;
}
.card-sub {
font-size: 12px;
color: var(--art-gray-400);
}
}
.pane-error {
margin-bottom: 12px;
}
.secret-row {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
.secret-input {
flex: 1;
min-width: 0;
}
}
.field-warn {
margin: 4px 0 0;
font-size: 12px;
color: var(--el-color-danger);
}
.switch-hint {
margin-left: 10px;
font-size: 12px;
color: var(--art-gray-400);
}
.wide-control {
width: 100%;
}
.save-bar {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.test-divider {
margin: 18px 0 12px;
}
.test-row {
display: flex;
gap: 10px;
align-items: center;
.test-input {
width: 280px;
}
}
.test-hint-inline {
font-size: 12px;
color: var(--art-gray-400);
}
.test-hint {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.6;
color: var(--art-gray-400);
}
.test-result {
margin-top: 10px;
}
</style>