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
+70 -1
View File
@@ -1,5 +1,6 @@
/**
* 配置中心 API 单元测试(Phase 3 Task 7a——品牌相关;Task 7b——试金石)
* 配置中心 API 单元测试(Phase 3 Task 7a——品牌相关;Task 7b——试金石;
* Task 7c——凭据测试端点)
*
* 钉住 API 层契约(事实源 app/platform/routes/config_routes.py 与
* upload_routes.py);HTTP 层行为已在 client.test.ts 覆盖,此处 mock http
@@ -9,6 +10,9 @@
* 包装),备注走 comment 查询参数,无备注时不带 params
* - POST wall/test(Task 7b):句子列表 + wall 覆盖(当前表单值预演,
* 不落库)+ has_contact/source 可选项——保存前自动验证复用本函数
* - POST email/test · push/test(Task 7c):前者 body 只带 {to}(用已
* 保存配置真发一封);后者无请求体(探测读已保存配置,空批量零线索
* 数据)——两者 showErrorMessage: false 内联展示
* - 头像上传:multipart file 字段(FormData)
* - normalizeBrandGroup:缺键补空串 / 类型漂移防御 / topics 只留字符串项
*/
@@ -24,6 +28,8 @@ import {
fetchTenantConfig,
normalizeBrandGroup,
putConfigGroup,
testEmail,
testPush,
testWall,
uploadBrandAvatar
} from '@/api/config'
@@ -194,6 +200,69 @@ describe('试金石 API(testWall · Phase 3 Task 7b)', () => {
})
})
describe('凭据测试 API(testEmail / testPush · Phase 3 Task 7c)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('testEmail:POST email/test,body 只带 {to}(收件人)', async () => {
const response = { slug: 'jiancai', sent: true, error: null }
vi.mocked(http.post).mockResolvedValue(response)
const res = await testEmail('jiancai', { to: 'you@example.com' }, {
showErrorMessage: false
})
expect(res).toEqual(response)
expect(http.post).toHaveBeenCalledTimes(1)
expect(http.post).toHaveBeenCalledWith(
'/api/platform/tenants/jiancai/email/test',
{ to: 'you@example.com' },
{ showErrorMessage: false } // 400「SMTP 未配置」等内联展示,不走全局提示
)
})
it('testEmail:发送失败也走成功响应通道(sent=false + error 串,非异常)', async () => {
const response = { slug: 'jiancai', sent: false, error: 'SMTPRecipientsRefused(...)' }
vi.mocked(http.post).mockResolvedValue(response)
const res = await testEmail('jiancai', { to: 'you@example.com' })
expect(res.sent).toBe(false)
expect(res.error).toContain('SMTP')
})
it('testPush:POST push/test,无请求体(探测读已保存配置)', async () => {
const response = {
slug: 'jiancai',
reachable: false,
auth_ok: null,
status: null,
reason: 'dns',
error: 'Failed to resolve.'
}
vi.mocked(http.post).mockResolvedValue(response)
const res = await testPush('jiancai', { showErrorMessage: false })
expect(res).toEqual(response)
expect(http.post).toHaveBeenCalledTimes(1)
const [url, body, config] = vi.mocked(http.post).mock.calls[0]
expect(url).toBe('/api/platform/tenants/jiancai/push/test')
expect(body).toBeUndefined() // 端点不读 body——探测参数全来自已保存配置
expect(config).toEqual({ showErrorMessage: false })
})
it('CONFIG_ENDPOINTS.emailTest / pushTest:端点路径与后端 config_routes 一致', () => {
expect(CONFIG_ENDPOINTS.emailTest('jiancai')).toBe(
'/api/platform/tenants/jiancai/email/test'
)
expect(CONFIG_ENDPOINTS.pushTest('jiancai')).toBe(
'/api/platform/tenants/jiancai/push/test'
)
})
})
describe('normalizeBrandGroup(GET 原样组 → 表单完整值)', () => {
it('缺键补空串、topics 缺省为空数组(快照是原样组,可能缺键)', () => {
const out = normalizeBrandGroup({ title: 'HESUPIPE Support' })
+72 -1
View File
@@ -25,6 +25,14 @@
* 400 带字段路径;has_contact / source 可选(默认 false / web);
* - 判定优先级与 conversation.py 执行侧一致:价格墙(blocked)→
* 零号码墙(replaced)→ 再索要墙(仅 has_contact,blocked 无替换)。
*
* ## Task 7c 凭据测试契约(config_routes.test_email / test_push)
*
* - POST email/test:body {to}——后端用**已保存**的当前邮件配置真发一封
* 测试邮件;SMTP 未配置 → 400;成败如实返回(sent=false 时带 error);
* - POST push/test:连通性探测——只验 URL 可达 + 鉴权,**不发任何线索
* 数据**(后端发空 messages 批量);不可达时 reason 分类
* dns/timeout/connect,auth_ok 三态(true/false/null=不定)。
*/
import { http, type RequestOptions } from './client'
@@ -132,6 +140,32 @@ export interface AvatarUploadResponse {
version: number
}
/** SMTP 测试邮件请求(POST .../email/test;用**已保存**配置真发一封到 to) */
export interface EmailTestRequest {
to: string
}
/** SMTP 测试邮件响应(发送失败不 5xx——sent=false 时 error 带原因) */
export interface EmailTestResponse {
slug: string
sent: boolean
error: string | null
}
/** 推送探测不可达原因分类(前端据此给修复提示文案) */
export type PushProbeReason = 'dns' | 'timeout' | 'connect'
/** 推送连通性探测响应(POST .../push/test;空批量预检,零线索数据) */
export interface PushTestResponse {
slug: string
reachable: boolean
/** true=2xx / false=401·403 / null=其他状态(到达但鉴权不定)或不可达 */
auth_ok: boolean | null
status: number | null
reason: PushProbeReason | null
error: string | null
}
/** 配置中心端点 */
export const CONFIG_ENDPOINTS = {
current: (slug: string) => `/api/platform/tenants/${slug}/config`,
@@ -144,7 +178,9 @@ export const CONFIG_ENDPOINTS = {
`/api/platform/tenants/${slug}/config/versions/${version}`,
rollback: (slug: string) => `/api/platform/tenants/${slug}/config/rollback`,
wallTest: (slug: string) => `/api/platform/tenants/${slug}/wall/test`,
avatarUpload: (slug: string) => `/api/platform/tenants/${slug}/brand/avatar`
avatarUpload: (slug: string) => `/api/platform/tenants/${slug}/brand/avatar`,
emailTest: (slug: string) => `/api/platform/tenants/${slug}/email/test`,
pushTest: (slug: string) => `/api/platform/tenants/${slug}/push/test`
} as const
/**
@@ -214,6 +250,41 @@ export function uploadBrandAvatar(
)
}
/**
* SMTP 测试邮件(POST .../email/test):用**已保存**的当前邮件配置真发一封
* 到 to——表单里未保存的编辑不生效(界面需提示"先保存再测试")。SMTP 未
* 配置 → 400(调用方 showErrorMessage: false 自行内联展示);发送失败
* 不抛错——sent=false 时 error 带原因串。
*/
export function testEmail(
slug: string,
body: EmailTestRequest,
config?: RequestOptions
): Promise<EmailTestResponse> {
return http.post<EmailTestResponse>(
CONFIG_ENDPOINTS.emailTest(slug),
body,
config
)
}
/**
* 推送连通性探测(POST .../push/test):只验 URL 可达 + 鉴权,后端发空
* messages 批量(零线索数据)。URL 未配置 → 400;结果三态由调用方按
* reachable/auth_ok 展示(不可达 reason 分类 dns/timeout/connect)。
* 同样用**已保存**配置——改完 URL 先保存再探测。
*/
export function testPush(
slug: string,
config?: RequestOptions
): Promise<PushTestResponse> {
return http.post<PushTestResponse>(
CONFIG_ENDPOINTS.pushTest(slug),
undefined,
config
)
}
/**
* GET 的 brand 组(原样快照,可能缺键/类型漂移)→ 表单友好的完整值。
* 缺键补空串作表单中性值:提交空串 = 显式清空覆盖出厂(出厂
+1
View File
@@ -78,6 +78,7 @@ declare module 'vue' {
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
+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>
@@ -1,5 +1,5 @@
/**
* 配置中心提示词/词表编辑模型单元测试(Phase 3 Task 7b)
* 配置中心提示词/词表/凭据编辑模型单元测试(Phase 3 Task 7b + 7c)
*
* 钉住 src/views/config/center/model.ts 的纯逻辑契约(事实源:
* app/platform/config_validation.py 的 PROMPTS_SIZE_CAPS 与
@@ -9,7 +9,11 @@
* - 词表:正则条目双形态(纯字符串 | {pattern, flags, comment})的
* 解析/序列化往返、空行剔除、语言池清洗、initWallEdit 对透传键的
* 保留、buildWallPayload 的整组重组;
* - 变体键镜像:reminders 六类 / cc_templates 四组与后端常量同构。
* - 变体键镜像:reminders 六类 / cc_templates 四组与后端常量同构;
* - 凭据三组(Task 7c):normalize(掩码哨兵态/数值转串/缺键防御)、
* build(密钥哨兵往返——不动保存回 '__SET__' 保持库值;空串 = 清空;
* 新值优先;数值空串 → null)与 validate 前端预检(端口 1-65535 /
* 邮箱含 @ / push URL 协议与正数·1-10 整数边界,与后端校验同款)。
*/
import { describe, expect, it } from 'vitest'
import {
@@ -19,13 +23,21 @@ import {
POOL_FIELDS,
PROMPTS_CAPS,
PROMPTS_KEYS,
PUSH_PROBE_REASON_LABELS,
REMINDER_VARIANTS,
TRIGGER_KEYS,
ZERO_NUMBER_KEYS,
asSecretMasked,
buildAiCredsPayload,
buildEmailCredsPayload,
buildPushCredsPayload,
buildWallPayload,
formatKB,
initWallEdit,
normalizeAiCredsGroup,
normalizeEmailCredsGroup,
normalizePromptsGroup,
normalizePushCredsGroup,
parsePatternEntry,
promptByteInfo,
promptsOverCap,
@@ -34,7 +46,11 @@ import {
rowToSingleEntry,
touchstoneSentences,
utf8Bytes,
type PromptsDraft
validateEmailCredsForm,
validatePushCredsForm,
type EmailCredsForm,
type PromptsDraft,
type PushCredsForm
} from '../model'
// ---- 提示词四件套 -------------------------------------------------------------------
@@ -357,3 +373,254 @@ describe('试金石句集', () => {
expect(touchstoneSentences([])).toEqual([])
})
})
// ---- 凭据三组(email / push / ai,Phase 3 Task 7c) -----------------------------------
describe('asSecretMasked(GET 密钥值 → 掩码哨兵态)', () => {
it('哨兵原样 / 空串与 null·undefined → 空串(区分「配了」与「没配」)', () => {
expect(asSecretMasked('__SET__')).toBe('__SET__')
expect(asSecretMasked('')).toBe('')
expect(asSecretMasked(null)).toBe('')
expect(asSecretMasked(undefined)).toBe('')
})
it('掩码失效漏出的任意非空值也归哨兵(防原始密钥回传库外)', () => {
expect(asSecretMasked('sk-real-secret')).toBe('__SET__')
expect(asSecretMasked(123)).toBe('')
})
})
describe('normalize 三组(GET 原样组 → 表单 + 密钥态)', () => {
it('email:smtp 完整组 → 表单值 + 密码哨兵;密钥输入恒空', () => {
const state = normalizeEmailCredsGroup({
smtp: { host: 'smtp.x.com', port: 465, user: 'bot@x.com', password: '__SET__' },
sender_name: 'HESUPIPE',
notify_emails: ['a@x.com', 'b@x.com']
})
expect(state.form).toEqual({
smtpHost: 'smtp.x.com',
smtpPort: '465',
smtpUser: 'bot@x.com',
smtpPassword: '',
senderName: 'HESUPIPE',
notifyEmails: ['a@x.com', 'b@x.com']
})
expect(state.passwordMasked).toBe('__SET__')
})
it('email:jiancai 档案形态(无 smtp 键)→ 全空槽 + 未设置密钥态', () => {
const state = normalizeEmailCredsGroup({
sender_name: 'HESUPIPE',
notify_emails: ['hesu@hesuu.com']
})
expect(state.form.smtpHost).toBe('')
expect(state.form.smtpPort).toBe('')
expect(state.form.smtpPassword).toBe('')
expect(state.passwordMasked).toBe('')
})
it('email:smtp null / 类型漂移防御', () => {
const state = normalizeEmailCredsGroup({ smtp: null, notify_emails: [1, 'a@x.com'] })
expect(state.form.smtpHost).toBe('')
expect(state.form.notifyEmails).toEqual(['a@x.com'])
expect(normalizeEmailCredsGroup(undefined).form.notifyEmails).toEqual([])
})
it('push:完整组 → 表单(数值转串)+ api_key 哨兵', () => {
const state = normalizePushCredsGroup({
enabled: true,
url: 'https://push.example/v1',
api_key: '__SET__',
prefix: 'ai-kefu-jc',
timeout: 5.5,
max_attempts: 3,
retry_backoff_base: 1
})
expect(state.form).toEqual({
enabled: true,
url: 'https://push.example/v1',
apiKey: '',
prefix: 'ai-kefu-jc',
timeout: '5.5',
maxAttempts: '3',
backoffBase: '1'
})
expect(state.apiKeyMasked).toBe('__SET__')
})
it('push:缺省/未启用 → enabled false + 数值空串(null → 空槽)', () => {
const state = normalizePushCredsGroup({ prefix: 'ai-kefu-jc' })
expect(state.form.enabled).toBe(false)
expect(state.form.timeout).toBe('')
expect(state.form.maxAttempts).toBe('')
expect(state.form.backoffBase).toBe('')
expect(state.form.apiKey).toBe('')
expect(state.apiKeyMasked).toBe('')
})
it('ai:缺省组 → 空表单(出厂默认由后端深合并兜底)+ 未设置密钥态', () => {
const state = normalizeAiCredsGroup(null)
expect(state.form).toEqual({ provider: '', apiKey: '', baseUrl: '', model: '' })
expect(state.apiKeyMasked).toBe('')
const set = normalizeAiCredsGroup({
provider: 'glm',
api_key: '__SET__',
model: 'glm-4-flash'
})
expect(set.form.provider).toBe('glm')
expect(set.form.apiKey).toBe('')
expect(set.apiKeyMasked).toBe('__SET__')
})
})
describe('build 三组载荷(表单 → PUT 整组)+ 密钥哨兵往返', () => {
it('email:普通字段 trim、空行剔除、端口转整数', () => {
const payload = buildEmailCredsPayload(
{
smtpHost: ' smtp.x.com ',
smtpPort: ' 465 ',
smtpUser: ' bot@x.com ',
smtpPassword: '',
senderName: ' HESUPIPE ',
notifyEmails: [' a@x.com ', '', ' ']
},
''
)
expect(payload).toEqual({
smtp: { host: 'smtp.x.com', port: 465, user: 'bot@x.com', password: '' },
sender_name: 'HESUPIPE',
notify_emails: ['a@x.com']
})
})
it('email 密钥三态:未设置不动 → 空串;已设置不动 → 哨兵;输入新值 → 新值', () => {
const form = (): EmailCredsForm => ({
smtpHost: 'smtp.x.com',
smtpPort: '465',
smtpUser: 'bot@x.com',
smtpPassword: '',
senderName: '',
notifyEmails: []
})
// 已设置 + 未动 → 哨兵(后端还原库值,库值不变)
expect(buildEmailCredsPayload(form(), '__SET__').smtp).toMatchObject({
password: '__SET__'
})
// 未设置 + 未动 → 空串(「清除」意图同为空串 = 显式清空)
expect(buildEmailCredsPayload(form(), '').smtp).toMatchObject({ password: '' })
// 输入新值 → 新值优先(无论原态)
const typed = { ...form(), smtpPassword: 'new-pw' }
expect(buildEmailCredsPayload(typed, '__SET__').smtp).toMatchObject({
password: 'new-pw'
})
})
it('push:数值空串 → null(未设置哨兵)、enabled 布尔原样、密钥哨兵往返', () => {
const payload = buildPushCredsPayload(
{
enabled: true,
url: ' https://push.example/v1 ',
apiKey: '',
prefix: 'demo-p',
timeout: '5.5',
maxAttempts: '3',
backoffBase: ''
},
'__SET__'
)
expect(payload).toEqual({
enabled: true,
url: 'https://push.example/v1',
api_key: '__SET__',
prefix: 'demo-p',
timeout: 5.5,
max_attempts: 3,
retry_backoff_base: null
})
})
it('ai:api_key 哨兵/新值/清空三态 + 字段 trim', () => {
const base = { provider: ' glm ', apiKey: '', baseUrl: ' https://x.com ', model: ' glm-4 ' }
expect(buildAiCredsPayload({ ...base }, '__SET__')).toEqual({
provider: 'glm',
api_key: '__SET__',
base_url: 'https://x.com',
model: 'glm-4'
})
expect(buildAiCredsPayload({ ...base, apiKey: 'sk-new' }, '').api_key).toBe('sk-new')
expect(buildAiCredsPayload({ ...base }, '').api_key).toBe('')
})
})
describe('validate 前端预检(后端 400 的前置层)', () => {
it('email:端口非整数/越界报错;空串合法(= 平台默认)', () => {
const ok: EmailCredsForm = {
smtpHost: '',
smtpPort: '',
smtpUser: '',
smtpPassword: '',
senderName: '',
notifyEmails: []
}
expect(validateEmailCredsForm(ok)).toEqual([])
for (const bad of ['0', '65536', 'abc', '1.5', '-1']) {
expect(validateEmailCredsForm({ ...ok, smtpPort: bad })).toHaveLength(1)
}
expect(validateEmailCredsForm({ ...ok, smtpPort: '465' })).toEqual([])
expect(validateEmailCredsForm({ ...ok, smtpPort: '65535' })).toEqual([])
})
it('email:通知邮箱非空行须含 @(空行提交时剔除,不报错)', () => {
const base: EmailCredsForm = {
smtpHost: '',
smtpPort: '',
smtpUser: '',
smtpPassword: '',
senderName: '',
notifyEmails: []
}
expect(validateEmailCredsForm({ ...base, notifyEmails: ['', ' '] })).toEqual([])
expect(
validateEmailCredsForm({ ...base, notifyEmails: ['a@x.com', 'not-an-email'] })
).toHaveLength(1)
})
it('push:URL 协议 / 正数 / 1-10 整数边界(与后端 _check_push 同款)', () => {
const ok: PushCredsForm = {
enabled: false,
url: 'https://push.example/v1',
apiKey: '',
prefix: '',
timeout: '10',
maxAttempts: '3',
backoffBase: '1'
}
expect(validatePushCredsForm(ok)).toEqual([])
// URL 空串合法(= 平台默认);非 http(s) 协议报错
expect(validatePushCredsForm({ ...ok, url: '' })).toEqual([])
expect(validatePushCredsForm({ ...ok, url: 'ftp://x' })).toHaveLength(1)
// timeout 正数
expect(validatePushCredsForm({ ...ok, timeout: '0' })).toHaveLength(1)
expect(validatePushCredsForm({ ...ok, timeout: '-1' })).toHaveLength(1)
expect(validatePushCredsForm({ ...ok, timeout: 'abc' })).toHaveLength(1)
expect(validatePushCredsForm({ ...ok, timeout: '0.5' })).toEqual([])
// max_attempts 1-10 整数
expect(validatePushCredsForm({ ...ok, maxAttempts: '0' })).toHaveLength(1)
expect(validatePushCredsForm({ ...ok, maxAttempts: '11' })).toHaveLength(1)
expect(validatePushCredsForm({ ...ok, maxAttempts: '2.5' })).toHaveLength(1)
expect(validatePushCredsForm({ ...ok, maxAttempts: '10' })).toEqual([])
// backoff 正数;全部空串 = 平台默认,合法
expect(validatePushCredsForm({ ...ok, backoffBase: '0' })).toHaveLength(1)
expect(
validatePushCredsForm({ ...ok, url: '', timeout: '', maxAttempts: '', backoffBase: '' })
).toEqual([])
})
})
describe('PUSH_PROBE_REASON_LABELS(不可达原因 → 修复提示)', () => {
it('覆盖后端 reason 全部分类(dns/timeout/connect)', () => {
expect(PUSH_PROBE_REASON_LABELS.dns).toContain('域名解析')
expect(PUSH_PROBE_REASON_LABELS.timeout).toContain('超时')
expect(PUSH_PROBE_REASON_LABELS.connect).toContain('拒绝')
})
})
+33 -5
View File
@@ -1,7 +1,7 @@
<!--
配置中心(Phase 3 Task 7a:品牌与嵌入代码;Task 7b:提示词四件套与
安全词表试金石;后续任务补凭据/版本)
- 页骨架:左侧组导航(品牌/提示词/安全词表实装;凭据/版本占位)
安全词表试金石;Task 7c:凭据表单与测试端点;后续任务补版本)
- 页骨架:左侧组导航(品牌/提示词/安全词表/凭据实装;版本占位)
- 租户上下文:超管下拉切换(复用会话页模式);租户管理员固定自己
- 品牌表单:GET config 整组读 → 表单编辑 → PUT config/brand 整组提交
(整组替换语义——字段清单 title/color/avatar/welcome/topics/placeholder/
@@ -14,6 +14,10 @@
试金石面板 POST wall/test(当前表单值预演,不落库);保存前自动跑
试金石,有拦截/替换时二次确认——PUT config/wall 整组,400 字段路径
内联展示
- 凭据(Task 7c,CredsPane):邮件/推送/AI 三组凭据表单(密钥哨兵
交互——不动保存回传 '__SET__' 保持库值,「清除」= 提交空串);数值
前端预检 + 后端 400 双层;SMTP 测试邮件(email/test 真发一封)与
推送连通性探测(push/test 空批量,零线索数据,三态展示)
- 挂件实时预览:**本地简化预览组件**(标题/颜色气泡/头像/话题/占位的
静态模拟,零后端依赖、即时反映表单值;真机预览留给嵌入代码上真站)
- 嵌入代码:域名取租户详情 sites[].domains(超管可枚举全租户,租户管理
@@ -79,7 +83,7 @@
<span v-if="IMPLEMENTED_GROUPS.has(item.key)" class="nav-badge">已实装</span>
</ElMenuItem>
</ElMenu>
<p class="nav-tip">凭据与版本在后续任务实装(当前为占位)。</p>
<p class="nav-tip">版本历史在后续任务实装(当前为占位)。</p>
</ElCard>
<!-- 右侧内容区 -->
@@ -397,6 +401,29 @@
</div>
</template>
<!-- 凭据 pane(Task 7c) -->
<template v-else-if="activeGroup === 'creds'">
<div v-loading="configLoading" class="group-pane">
<ElAlert
v-if="configError"
type="error"
:closable="false"
:title="configError"
class="pane-error"
>
<ElButton size="small" :loading="configLoading" @click="reloadConfig">重试</ElButton>
</ElAlert>
<CredsPane
v-else-if="activeSlug"
:slug="activeSlug"
:email-group="snapshot?.email"
:push-group="snapshot?.push"
:ai-group="snapshot?.ai"
@saved="onPaneSaved"
/>
</div>
</template>
<!-- 其余组占位 -->
<ElCard v-else shadow="never" class="placeholder-card">
<ElEmpty :description="`${activeGroupLabel}——后续任务实装`" />
@@ -418,6 +445,7 @@
} from '@/api/config'
import PromptsPane from './PromptsPane.vue'
import WallPane from './WallPane.vue'
import CredsPane from './CredsPane.vue'
defineOptions({ name: 'ConfigCenter' })
@@ -428,7 +456,7 @@
const AVATAR_MAX_BYTES = 2 * 1024 * 1024
const AVATAR_EXTS = ['png', 'jpg', 'jpeg', 'webp']
// ---- 左侧组导航(Task 7a 品牌 / Task 7b 提示词+词表实装;其余占位) --------------------
// ---- 左侧组导航(Task 7a 品牌 / Task 7b 提示词+词表 / Task 7c 凭据实装;版本占位) ------
const GROUP_NAV = [
{ key: 'brand', label: '品牌' },
@@ -439,7 +467,7 @@
] as const
/** 已实装组(导航徽标) */
const IMPLEMENTED_GROUPS = new Set<GroupKey>(['brand', 'prompts', 'wall'])
const IMPLEMENTED_GROUPS = new Set<GroupKey>(['brand', 'prompts', 'wall', 'creds'])
type GroupKey = (typeof GROUP_NAV)[number]['key']
+256 -4
View File
@@ -1,8 +1,8 @@
/**
* 配置中心提示词/词表编辑模型(Phase 3 Task 7b)
* 配置中心提示词/词表/凭据编辑模型(Phase 3 Task 7b + 7c)
*
* 消费方:config/center 的 PromptsPane.vue / WallPane.vue。纯逻辑无 DOM
* 依赖,vitest 直接覆盖(__tests__/model.test.ts)。
* 消费方:config/center 的 PromptsPane.vue / WallPane.vue / CredsPane.vue。
* 纯逻辑无 DOM 依赖,vitest 直接覆盖(__tests__/model.test.ts)。
*
* 事实源对齐:
* - 提示词四件套键名/字节上限:app/engine/services/ai_service.PROMPTS_KEYS
@@ -14,8 +14,15 @@
* comment?}(flags 仅支持 'i');PUT wall 整组提交,未结构化编辑的
* 顶层键(含 "_" 前缀元信息键)原样透传;
* - 语言池必填 en/zh(wall_schema._REQUIRED_POOLS),reminders 六类 /
* cc_templates 四组的变体键为封闭集合(未知键编译期显性拒绝)。
* cc_templates 四组的变体键为封闭集合(未知键编译期显性拒绝);
* - 凭据三组(Task 7c):DEFAULT_CONFIG 的 email/push/ai 组键 +
* config_validation 数值边界(push.timeout/backoff 正数、max_attempts
* 1-10 整数、notify_emails 项含 @)+ config_views 空槽回退语义
* (空串/None = 未设置 → 回退 env,表单提示「留空 = 平台默认」)。
* 密钥哨兵交互见 src/api/mask.ts(GET '__SET__' → 空输入 + 占位符;
* 不动保存回传哨兵保持库值;「清除」= 提交空串)。
*/
import { MASKED_SENTINEL, secretToSubmit } from '@/api/mask'
// ---- 提示词四件套 -----------------------------------------------------------------
/** 四件套键名(ai_service.PROMPTS_KEYS 镜像;prompts 是整组 PUT 的四键 dict) */
@@ -423,3 +430,248 @@ export const DEFAULT_TOUCHSTONE_SENTENCES = [
export function touchstoneSentences(sentences: string[]): string[] {
return sentences.map((s) => s.trim()).filter(Boolean)
}
// ---- 凭据三组(email / push / ai,Phase 3 Task 7c) --------------------------------
/** 邮件凭据表单(数值/密钥输入均为字符串形态——空串 = 未设置回退 env) */
export interface EmailCredsForm {
smtpHost: string
smtpPort: string
smtpUser: string
/** 密钥输入:初始恒空,「已设置」态由占位符表达(见 mask.ts) */
smtpPassword: string
senderName: string
notifyEmails: string[]
}
/** 推送凭据表单(timeout/maxAttempts/backoffBase:'' = null = 平台默认) */
export interface PushCredsForm {
enabled: boolean
url: string
apiKey: string
prefix: string
timeout: string
maxAttempts: string
backoffBase: string
}
/** AI 凭据表单 */
export interface AiCredsForm {
provider: string
apiKey: string
baseUrl: string
model: string
}
/** GET 组 → 表单 + 密钥掩码状态(掩码值驱动占位符与 secretToSubmit) */
export interface EmailCredsState {
form: EmailCredsForm
passwordMasked: string
}
export interface PushCredsState {
form: PushCredsForm
apiKeyMasked: string
}
export interface AiCredsState {
apiKeyMasked: string
form: AiCredsForm
}
/**
* 任意非空字符串 → 掩码哨兵。比 isMaskedSentinel 更保守:即使后端掩码
* 失效漏出原始密钥(理论不可达),也按「已设置」处理——不动保存回传
* 哨兵由后端还原库值,绝不把原始值再提交回去;空串/null/undefined → ''
* (未设置)。
*/
export function asSecretMasked(value: unknown): string {
return typeof value === 'string' && value !== '' ? MASKED_SENTINEL : ''
}
/** 防御读取:数值 → 表单串(非有限数值/缺省 → '') */
function numberToForm(value: unknown): string {
return typeof value === 'number' && Number.isFinite(value) ? String(value) : ''
}
/** 防御读取:字符串数组(非字符串项剔除) */
function strList(value: unknown): string[] {
return Array.isArray(value)
? value.filter((v): v is string => typeof v === 'string')
: []
}
/** 表单串 → 整数 | null(空串 = 未设置;非整数由 validate 前置拦截) */
function intOrNull(value: string): number | null {
const n = Number(value.trim())
return value.trim() !== '' && Number.isInteger(n) ? n : null
}
/** 表单串 → 数值 | null(空串 = 未设置;非数值由 validate 前置拦截) */
function numOrNull(value: string): number | null {
const n = Number(value.trim())
return value.trim() !== '' && Number.isFinite(n) ? n : null
}
/** GET 的 email 组 → 邮件表单(smtp 缺省/null → 全空槽,回退 env) */
export function normalizeEmailCredsGroup(
value: Record<string, unknown> | null | undefined
): EmailCredsState {
const source = value ?? {}
const smtp = asRecord(source.smtp) ?? {}
return {
form: {
smtpHost: asStr(smtp.host),
smtpPort: numberToForm(smtp.port),
smtpUser: asStr(smtp.user),
smtpPassword: '',
senderName: asStr(source.sender_name),
notifyEmails: strList(source.notify_emails)
},
passwordMasked: asSecretMasked(smtp.password)
}
}
/** GET 的 push 组 → 推送表单 */
export function normalizePushCredsGroup(
value: Record<string, unknown> | null | undefined
): PushCredsState {
const source = value ?? {}
return {
form: {
enabled: source.enabled === true,
url: asStr(source.url),
apiKey: '',
prefix: asStr(source.prefix),
timeout: numberToForm(source.timeout),
maxAttempts: numberToForm(source.max_attempts),
backoffBase: numberToForm(source.retry_backoff_base)
},
apiKeyMasked: asSecretMasked(source.api_key)
}
}
/** GET 的 ai 组 → AI 表单 */
export function normalizeAiCredsGroup(
value: Record<string, unknown> | null | undefined
): AiCredsState {
const source = value ?? {}
return {
form: {
provider: asStr(source.provider),
apiKey: '',
baseUrl: asStr(source.base_url),
model: asStr(source.model)
},
apiKeyMasked: asSecretMasked(source.api_key)
}
}
/**
* 邮件表单 → PUT config/email 整组载荷。密钥走 secretToSubmit(未动 →
* 哨兵保持库值;「清除」按钮已把 maskedState 置 '',提交空串 = 显式清空)。
* 数值/邮箱格式由 validateEmailCredsForm 前置把关(后端 400 二层兜底)。
*/
export function buildEmailCredsPayload(
form: EmailCredsForm,
passwordMasked: string
): Record<string, unknown> {
return {
smtp: {
host: form.smtpHost.trim(),
port: intOrNull(form.smtpPort),
user: form.smtpUser.trim(),
password: secretToSubmit(form.smtpPassword, passwordMasked)
},
sender_name: form.senderName.trim(),
notify_emails: form.notifyEmails
.map((e) => e.trim())
.filter((e) => e !== '')
}
}
/** 推送表单 → PUT config/push 整组载荷(prefix 冲突 400 由后端跨租户查重) */
export function buildPushCredsPayload(
form: PushCredsForm,
apiKeyMasked: string
): Record<string, unknown> {
return {
enabled: form.enabled,
url: form.url.trim(),
api_key: secretToSubmit(form.apiKey, apiKeyMasked),
prefix: form.prefix.trim(),
timeout: numOrNull(form.timeout),
max_attempts: intOrNull(form.maxAttempts),
retry_backoff_base: numOrNull(form.backoffBase)
}
}
/** AI 表单 → PUT config/ai 整组载荷 */
export function buildAiCredsPayload(
form: AiCredsForm,
apiKeyMasked: string
): Record<string, unknown> {
return {
provider: form.provider.trim(),
api_key: secretToSubmit(form.apiKey, apiKeyMasked),
base_url: form.baseUrl.trim(),
model: form.model.trim()
}
}
/** 邮件表单预检(空槽合法——回退 env;只查非空行的格式) */
export function validateEmailCredsForm(form: EmailCredsForm): string[] {
const errors: string[] = []
const port = form.smtpPort.trim()
if (port !== '') {
const n = Number(port)
if (!Number.isInteger(n) || n < 1 || n > 65535) {
errors.push('SMTP 端口:须为 1-65535 的整数(留空 = 平台默认)')
}
}
form.notifyEmails.forEach((row, i) => {
const t = row.trim()
if (t !== '' && !t.includes('@')) {
errors.push(`通知邮箱第 ${i + 1} 行:须为含 @ 的邮箱地址`)
}
})
return errors
}
/** 推送表单预检(与后端 _check_push 的数值边界同款:正数 / 1-10 整数) */
export function validatePushCredsForm(form: PushCredsForm): string[] {
const errors: string[] = []
const url = form.url.trim()
if (url !== '' && !/^https?:\/\//.test(url)) {
errors.push('推送 URL:须以 http:// 或 https:// 开头')
}
const timeout = form.timeout.trim()
if (timeout !== '') {
const n = Number(timeout)
if (!Number.isFinite(n) || n <= 0) {
errors.push('推送超时(秒):须为正数(留空 = 平台默认)')
}
}
const attempts = form.maxAttempts.trim()
if (attempts !== '') {
const n = Number(attempts)
if (!Number.isInteger(n) || n < 1 || n > 10) {
errors.push('最大尝试次数:须为 1-10 的整数(留空 = 平台默认)')
}
}
const backoff = form.backoffBase.trim()
if (backoff !== '') {
const n = Number(backoff)
if (!Number.isFinite(n) || n <= 0) {
errors.push('重试退避基数(秒):须为正数(留空 = 平台默认)')
}
}
return errors
}
/** 推送探测不可达原因 → 修复提示文案(后端 reason 分类的前端展示层) */
export const PUSH_PROBE_REASON_LABELS: Record<string, string> = {
dns: '域名解析失败(检查 URL 拼写)',
timeout: '连接超时',
connect: '连接被拒绝或网络不可达'
}