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 与 * 钉住 API 层契约(事实源 app/platform/routes/config_routes.py 与
* upload_routes.py);HTTP 层行为已在 client.test.ts 覆盖,此处 mock http * upload_routes.py);HTTP 层行为已在 client.test.ts 覆盖,此处 mock http
@@ -9,6 +10,9 @@
* 包装),备注走 comment 查询参数,无备注时不带 params * 包装),备注走 comment 查询参数,无备注时不带 params
* - POST wall/test(Task 7b):句子列表 + wall 覆盖(当前表单值预演, * - POST wall/test(Task 7b):句子列表 + wall 覆盖(当前表单值预演,
* 不落库)+ has_contact/source 可选项——保存前自动验证复用本函数 * 不落库)+ has_contact/source 可选项——保存前自动验证复用本函数
* - POST email/test · push/test(Task 7c):前者 body 只带 {to}(用已
* 保存配置真发一封);后者无请求体(探测读已保存配置,空批量零线索
* 数据)——两者 showErrorMessage: false 内联展示
* - 头像上传:multipart file 字段(FormData) * - 头像上传:multipart file 字段(FormData)
* - normalizeBrandGroup:缺键补空串 / 类型漂移防御 / topics 只留字符串项 * - normalizeBrandGroup:缺键补空串 / 类型漂移防御 / topics 只留字符串项
*/ */
@@ -24,6 +28,8 @@ import {
fetchTenantConfig, fetchTenantConfig,
normalizeBrandGroup, normalizeBrandGroup,
putConfigGroup, putConfigGroup,
testEmail,
testPush,
testWall, testWall,
uploadBrandAvatar uploadBrandAvatar
} from '@/api/config' } 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 原样组 → 表单完整值)', () => { describe('normalizeBrandGroup(GET 原样组 → 表单完整值)', () => {
it('缺键补空串、topics 缺省为空数组(快照是原样组,可能缺键)', () => { it('缺键补空串、topics 缺省为空数组(快照是原样组,可能缺键)', () => {
const out = normalizeBrandGroup({ title: 'HESUPIPE Support' }) const out = normalizeBrandGroup({ title: 'HESUPIPE Support' })
+72 -1
View File
@@ -25,6 +25,14 @@
* 400 带字段路径;has_contact / source 可选(默认 false / web); * 400 带字段路径;has_contact / source 可选(默认 false / web);
* - 判定优先级与 conversation.py 执行侧一致:价格墙(blocked)→ * - 判定优先级与 conversation.py 执行侧一致:价格墙(blocked)→
* 零号码墙(replaced)→ 再索要墙(仅 has_contact,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' import { http, type RequestOptions } from './client'
@@ -132,6 +140,32 @@ export interface AvatarUploadResponse {
version: number 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 = { export const CONFIG_ENDPOINTS = {
current: (slug: string) => `/api/platform/tenants/${slug}/config`, current: (slug: string) => `/api/platform/tenants/${slug}/config`,
@@ -144,7 +178,9 @@ export const CONFIG_ENDPOINTS = {
`/api/platform/tenants/${slug}/config/versions/${version}`, `/api/platform/tenants/${slug}/config/versions/${version}`,
rollback: (slug: string) => `/api/platform/tenants/${slug}/config/rollback`, rollback: (slug: string) => `/api/platform/tenants/${slug}/config/rollback`,
wallTest: (slug: string) => `/api/platform/tenants/${slug}/wall/test`, 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 } 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 组(原样快照,可能缺键/类型漂移)→ 表单友好的完整值。 * GET 的 brand 组(原样快照,可能缺键/类型漂移)→ 表单友好的完整值。
* 缺键补空串作表单中性值:提交空串 = 显式清空覆盖出厂(出厂 * 缺键补空串作表单中性值:提交空串 = 显式清空覆盖出厂(出厂
+1
View File
@@ -78,6 +78,7 @@ declare module 'vue' {
ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] 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 的纯逻辑契约(事实源: * 钉住 src/views/config/center/model.ts 的纯逻辑契约(事实源:
* app/platform/config_validation.py 的 PROMPTS_SIZE_CAPS 与 * app/platform/config_validation.py 的 PROMPTS_SIZE_CAPS 与
@@ -9,7 +9,11 @@
* - 词表:正则条目双形态(纯字符串 | {pattern, flags, comment})的 * - 词表:正则条目双形态(纯字符串 | {pattern, flags, comment})的
* 解析/序列化往返、空行剔除、语言池清洗、initWallEdit 对透传键的 * 解析/序列化往返、空行剔除、语言池清洗、initWallEdit 对透传键的
* 保留、buildWallPayload 的整组重组; * 保留、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 { describe, expect, it } from 'vitest'
import { import {
@@ -19,13 +23,21 @@ import {
POOL_FIELDS, POOL_FIELDS,
PROMPTS_CAPS, PROMPTS_CAPS,
PROMPTS_KEYS, PROMPTS_KEYS,
PUSH_PROBE_REASON_LABELS,
REMINDER_VARIANTS, REMINDER_VARIANTS,
TRIGGER_KEYS, TRIGGER_KEYS,
ZERO_NUMBER_KEYS, ZERO_NUMBER_KEYS,
asSecretMasked,
buildAiCredsPayload,
buildEmailCredsPayload,
buildPushCredsPayload,
buildWallPayload, buildWallPayload,
formatKB, formatKB,
initWallEdit, initWallEdit,
normalizeAiCredsGroup,
normalizeEmailCredsGroup,
normalizePromptsGroup, normalizePromptsGroup,
normalizePushCredsGroup,
parsePatternEntry, parsePatternEntry,
promptByteInfo, promptByteInfo,
promptsOverCap, promptsOverCap,
@@ -34,7 +46,11 @@ import {
rowToSingleEntry, rowToSingleEntry,
touchstoneSentences, touchstoneSentences,
utf8Bytes, utf8Bytes,
type PromptsDraft validateEmailCredsForm,
validatePushCredsForm,
type EmailCredsForm,
type PromptsDraft,
type PushCredsForm
} from '../model' } from '../model'
// ---- 提示词四件套 ------------------------------------------------------------------- // ---- 提示词四件套 -------------------------------------------------------------------
@@ -357,3 +373,254 @@ describe('试金石句集', () => {
expect(touchstoneSentences([])).toEqual([]) 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:提示词四件套与 配置中心(Phase 3 Task 7a:品牌与嵌入代码;Task 7b:提示词四件套与
安全词表试金石;后续任务补凭据/版本) 安全词表试金石;Task 7c:凭据表单与测试端点;后续任务补版本)
- 页骨架:左侧组导航(品牌/提示词/安全词表实装;凭据/版本占位) - 页骨架:左侧组导航(品牌/提示词/安全词表/凭据实装;版本占位)
- 租户上下文:超管下拉切换(复用会话页模式);租户管理员固定自己 - 租户上下文:超管下拉切换(复用会话页模式);租户管理员固定自己
- 品牌表单:GET config 整组读 → 表单编辑 → PUT config/brand 整组提交 - 品牌表单:GET config 整组读 → 表单编辑 → PUT config/brand 整组提交
(整组替换语义——字段清单 title/color/avatar/welcome/topics/placeholder/ (整组替换语义——字段清单 title/color/avatar/welcome/topics/placeholder/
@@ -14,6 +14,10 @@
试金石面板 POST wall/test(当前表单值预演,不落库);保存前自动跑 试金石面板 POST wall/test(当前表单值预演,不落库);保存前自动跑
试金石,有拦截/替换时二次确认——PUT config/wall 整组,400 字段路径 试金石,有拦截/替换时二次确认——PUT config/wall 整组,400 字段路径
内联展示 内联展示
- 凭据(Task 7c,CredsPane):邮件/推送/AI 三组凭据表单(密钥哨兵
交互——不动保存回传 '__SET__' 保持库值,「清除」= 提交空串);数值
前端预检 + 后端 400 双层;SMTP 测试邮件(email/test 真发一封)与
推送连通性探测(push/test 空批量,零线索数据,三态展示)
- 挂件实时预览:**本地简化预览组件**(标题/颜色气泡/头像/话题/占位的 - 挂件实时预览:**本地简化预览组件**(标题/颜色气泡/头像/话题/占位的
静态模拟,零后端依赖、即时反映表单值;真机预览留给嵌入代码上真站) 静态模拟,零后端依赖、即时反映表单值;真机预览留给嵌入代码上真站)
- 嵌入代码:域名取租户详情 sites[].domains(超管可枚举全租户,租户管理 - 嵌入代码:域名取租户详情 sites[].domains(超管可枚举全租户,租户管理
@@ -79,7 +83,7 @@
<span v-if="IMPLEMENTED_GROUPS.has(item.key)" class="nav-badge">已实装</span> <span v-if="IMPLEMENTED_GROUPS.has(item.key)" class="nav-badge">已实装</span>
</ElMenuItem> </ElMenuItem>
</ElMenu> </ElMenu>
<p class="nav-tip">凭据与版本在后续任务实装(当前为占位)。</p> <p class="nav-tip">版本历史在后续任务实装(当前为占位)。</p>
</ElCard> </ElCard>
<!-- 右侧内容区 --> <!-- 右侧内容区 -->
@@ -397,6 +401,29 @@
</div> </div>
</template> </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"> <ElCard v-else shadow="never" class="placeholder-card">
<ElEmpty :description="`${activeGroupLabel}——后续任务实装`" /> <ElEmpty :description="`${activeGroupLabel}——后续任务实装`" />
@@ -418,6 +445,7 @@
} from '@/api/config' } from '@/api/config'
import PromptsPane from './PromptsPane.vue' import PromptsPane from './PromptsPane.vue'
import WallPane from './WallPane.vue' import WallPane from './WallPane.vue'
import CredsPane from './CredsPane.vue'
defineOptions({ name: 'ConfigCenter' }) defineOptions({ name: 'ConfigCenter' })
@@ -428,7 +456,7 @@
const AVATAR_MAX_BYTES = 2 * 1024 * 1024 const AVATAR_MAX_BYTES = 2 * 1024 * 1024
const AVATAR_EXTS = ['png', 'jpg', 'jpeg', 'webp'] const AVATAR_EXTS = ['png', 'jpg', 'jpeg', 'webp']
// ---- 左侧组导航(Task 7a 品牌 / Task 7b 提示词+词表实装;其余占位) -------------------- // ---- 左侧组导航(Task 7a 品牌 / Task 7b 提示词+词表 / Task 7c 凭据实装;版本占位) ------
const GROUP_NAV = [ const GROUP_NAV = [
{ key: 'brand', label: '品牌' }, { key: 'brand', label: '品牌' },
@@ -439,7 +467,7 @@
] as const ] 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'] 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 * 消费方:config/center 的 PromptsPane.vue / WallPane.vue / CredsPane.vue。
* 依赖,vitest 直接覆盖(__tests__/model.test.ts)。 * 纯逻辑无 DOM 依赖,vitest 直接覆盖(__tests__/model.test.ts)。
* *
* 事实源对齐: * 事实源对齐:
* - 提示词四件套键名/字节上限:app/engine/services/ai_service.PROMPTS_KEYS * - 提示词四件套键名/字节上限:app/engine/services/ai_service.PROMPTS_KEYS
@@ -14,8 +14,15 @@
* comment?}(flags 仅支持 'i');PUT wall 整组提交,未结构化编辑的 * comment?}(flags 仅支持 'i');PUT wall 整组提交,未结构化编辑的
* 顶层键(含 "_" 前缀元信息键)原样透传; * 顶层键(含 "_" 前缀元信息键)原样透传;
* - 语言池必填 en/zh(wall_schema._REQUIRED_POOLS),reminders 六类 / * - 语言池必填 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) */ /** 四件套键名(ai_service.PROMPTS_KEYS 镜像;prompts 是整组 PUT 的四键 dict) */
@@ -423,3 +430,248 @@ export const DEFAULT_TOUCHSTONE_SENTENCES = [
export function touchstoneSentences(sentences: string[]): string[] { export function touchstoneSentences(sentences: string[]): string[] {
return sentences.map((s) => s.trim()).filter(Boolean) 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: '连接被拒绝或网络不可达'
}
+45 -5
View File
@@ -1,5 +1,6 @@
import html import html
import logging import logging
from datetime import datetime, timedelta, timezone
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
@@ -58,16 +59,23 @@ def _cc_views(contacts: dict, cc_info: dict | None) -> tuple[dict, dict]:
return body, subj return body, subj
async def _smtp_send(msg: MIMEMultipart, cfg: dict) -> None:
"""底层连接发送(Phase 3 Task 7c 从 _send 抽出): 直调 aiosmtplib,
失败抛原始异常——需要错误串的调用方(配置中心测试邮件 send_test_email)
复用同一连接逻辑;_send 在此之上包"吞异常返 bool"契约,行为不变。"""
await aiosmtplib.send(
msg, hostname=cfg["host"], port=cfg["port"],
username=cfg["user"], password=cfg["password"],
use_tls=True,
)
async def _send(msg: MIMEMultipart, cfg: dict) -> bool: async def _send(msg: MIMEMultipart, cfg: dict) -> bool:
if not all([cfg["host"], cfg["user"], cfg["notify_emails"]]): if not all([cfg["host"], cfg["user"], cfg["notify_emails"]]):
logger.warning("SMTP not configured, skipping email") logger.warning("SMTP not configured, skipping email")
return False return False
try: try:
await aiosmtplib.send( await _smtp_send(msg, cfg)
msg, hostname=cfg["host"], port=cfg["port"],
username=cfg["user"], password=cfg["password"],
use_tls=True,
)
logger.info("Lead email sent to %s", cfg["notify_emails"]) logger.info("Lead email sent to %s", cfg["notify_emails"])
return True return True
except Exception: except Exception:
@@ -170,3 +178,35 @@ async def send_correction_email(
f"<table>{_contact_rows(body_contacts)}</table>" f"<table>{_contact_rows(body_contacts)}</table>"
f"</body></html>", "html", "utf-8")) f"</body></html>", "html", "utf-8"))
return await _send(msg, cfg) return await _send(msg, cfg)
async def send_test_email(
to: str, tenant_name: str = "",
config_data: dict | None = None,
) -> tuple[bool, str | None]:
"""配置中心测试邮件(Phase 3 Task 7c): 按当前生效邮件配置真发一封到
指定收件人——主题/正文钉契约("[all-kefu] SMTP 测试邮件"+租户名+时间,
时间与 lead_push_service 同款 +08:00 口径)。
与 _send 的吞异常 bool 契约解耦: 成败与错误串如实返回(探测端点直传
前端,凭据对不对让收件箱与错误串说话);连接逻辑复用 _smtp_send。
"SMTP 未配置"的 400 判定在端点层(本函数假定 cfg 已可用)。"""
cfg = resolve_email_settings(config_data)
now = datetime.now(timezone(timedelta(hours=8)))
msg = MIMEMultipart("alternative")
msg["Subject"] = "[all-kefu] SMTP 测试邮件"
msg["From"] = f"【{cfg['sender_name']}】 <{cfg['user']}>"
msg["To"] = to
msg.attach(MIMEText(
f"<html><body>"
f"<p>这是一封 SMTP 配置测试邮件——收到本邮件说明该租户的邮件配置"
f"可以正常发信。</p>"
f"<p>租户:{_esc(tenant_name)}</p>"
f"<p>时间:{now.strftime('%Y-%m-%d %H:%M:%S')}(UTC+8)</p>"
f"</body></html>", "html", "utf-8"))
try:
await _smtp_send(msg, cfg)
return True, None
except Exception as exc:
logger.exception("Test email to %s failed", to)
return False, repr(exc)
+114
View File
@@ -19,6 +19,10 @@
缺省用当前墙——逐句调 boundary_filter 纯函数给判定,**不落库不 bump 版本** 缺省用当前墙——逐句调 boundary_filter 纯函数给判定,**不落库不 bump 版本**
(reset/编辑器的预览入口)。判定优先级与 conversation.py 执行侧一致: (reset/编辑器的预览入口)。判定优先级与 conversation.py 执行侧一致:
价格墙 → 零号码墙 → 再索要墙(仅 has_contact); 价格墙 → 零号码墙 → 再索要墙(仅 has_contact);
- POST /email/test 与 /push/test(凭据测试,Phase 3 Task 7c):分别用
resolve_email_settings/resolve_push_settings 的当前配置真发测试邮件 /
探测推送连通性——**push 探测绝不携带线索数据**(空 messages 批量),
两端点不落库不 bump 版本,成败如实返回(400 只用于"未配置"预检);
- GET/PUT /api/platform/settings:PlatformSetting CRUD,仅超管。本阶段只落 - GET/PUT /api/platform/settings:PlatformSetting CRUD,仅超管。本阶段只落
存储与接口(value 以 JSON Text 直存,读回还原类型);resolver 的平台默认 存储与接口(value 以 JSON Text 直存,读回还原类型);resolver 的平台默认
回退链接入归 Phase 3。 回退链接入归 Phase 3。
@@ -36,12 +40,15 @@ email.smtp.password 及平台设置的密钥名值,所有携带 config/value 的
""" """
import copy import copy
import json import json
import socket
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.engine.services import boundary_filter from app.engine.services import boundary_filter
from app.engine.services.email_service import send_test_email
from app.engine.wall_schema import WallConfigError, compile_wall from app.engine.wall_schema import WallConfigError, compile_wall
from app.platform.auth import UserContext, require_super from app.platform.auth import UserContext, require_super
from app.platform.config_validation import ( from app.platform.config_validation import (
@@ -49,6 +56,10 @@ from app.platform.config_validation import (
ValidationContext, ValidationContext,
validate_group, validate_group,
) )
from app.platform.config_views import (
resolve_email_settings,
resolve_push_settings,
)
from app.platform.db import ( from app.platform.db import (
PlatformSetting, PlatformSetting,
Tenant, Tenant,
@@ -488,6 +499,109 @@ async def test_wall(slug: str, request: Request, user=Depends(_tenant_guard)):
return {"slug": slug, "wall_source": wall_source, "results": results} return {"slug": slug, "wall_source": wall_source, "results": results}
# ---- 凭据测试端点(email/push test,Phase 3 Task 7c) -------------------------------
_EMAIL_TEST_KEYS = frozenset({"to"})
@router.post("/{slug}/email/test")
async def test_email(slug: str, request: Request,
user=Depends(_tenant_guard)):
"""SMTP 测试邮件: 用当前生效邮件配置(resolve_email_settings,租户组
优先/空槽回退 env SMTP_*)真发一封到 body.to——主题/正文契约见
email_service.send_test_email。SMTP 未配置(回退后 host/user 仍空)
→ 400;发送失败不 5xx,{sent: false, error} 如实返回。不落库不 bump
版本;密钥绝不进响应。"""
body = await _json_body(request)
unknown = set(body) - _EMAIL_TEST_KEYS
if unknown:
raise HTTPException(
status_code=400,
detail=f"未知字段: {', '.join(sorted(unknown))}(合法: to)")
to = body.get("to")
if not isinstance(to, str) or "@" not in to.strip():
raise HTTPException(
status_code=400,
detail=f"to: 须为含 @ 的收件人邮箱(实际 {to!r})")
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
tenant = await _get_tenant_or_404(session, slug)
_, snapshot = await _load_current(session, tenant.id, slug)
cfg = resolve_email_settings(snapshot)
if not cfg["host"] or not cfg["user"]:
raise HTTPException(
status_code=400,
detail="SMTP 未配置(服务器/账号在租户配置与环境回退后均为空)"
"——请先在「配置中心 → 凭据」保存邮件 SMTP")
sent, error = await send_test_email(to.strip(), tenant.name, snapshot)
return {"slug": slug, "sent": sent, "error": error}
def _transport_reason(exc: Exception) -> str:
"""传输异常 → 不可达原因分类(前端三态展示): dns(域名解析失败)/
timeout(TimeoutException 族)/connect(拒绝·不可达·TLS 等其余)。
dns 判定双通道: 沿 __cause__/__context__ 链找 gaierror(httpx 把解析
错误包在 ConnectError 里),并对链上异常做文案匹配——Windows 实测
(冒烟 Task 7c)httpx/anyio 会把 errno 11001 包成裸 ConnectError,
cause 链不带 gaierror,只能从 "[Errno 11001] getaddrinfo failed"
文案识别;分类仅供修复提示,错误原文始终随响应返回兜底。"""
node: BaseException | None = exc
while node is not None:
if isinstance(node, socket.gaierror):
return "dns"
text = str(node).lower()
if "getaddrinfo" in text or "name or service not known" in text:
return "dns"
node = node.__cause__ or node.__context__
if isinstance(exc, httpx.TimeoutException):
return "timeout"
return "connect"
async def _probe_push(cfg: dict) -> dict:
"""单次连通性探测(无重试): 与 lead_push_service._do_post 同款连接形态
(timeout/follow_redirects/X-Api-Key 头),body 恒为空 messages 列表——
**绝不携带任何线索数据**(UniService 是批量结构,空批量对方案应为
无操作;契约文档不在库内,这是可用的最保守预检形态)。"""
try:
async with httpx.AsyncClient(timeout=cfg["timeout"],
follow_redirects=True) as client:
resp = await client.post(
cfg["url"], json={"messages": []},
headers={"X-Api-Key": cfg["api_key"]})
except httpx.HTTPError as exc:
return {"reachable": False, "auth_ok": None, "status": None,
"reason": _transport_reason(exc),
"error": str(exc) or repr(exc)}
auth_ok = (True if resp.status_code < 300
else False if resp.status_code in (401, 403)
else None)
return {"reachable": True, "auth_ok": auth_ok,
"status": resp.status_code, "reason": None, "error": None}
@router.post("/{slug}/push/test")
async def test_push(slug: str, request: Request,
user=Depends(_tenant_guard)):
"""推送连通性探测: 只验 URL 可达 + 鉴权通过,不发真数据(空 messages
批量,见 _probe_push)。URL 回退后仍空 → 400;其余如实返回——不可达
(含 dns/timeout/connect 原因分类)/可达但鉴权失败(401/403)/正常(2xx);
其余状态码可达但鉴权不定(auth_ok=null)。不落库不 bump 版本。"""
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
tenant = await _get_tenant_or_404(session, slug)
_, snapshot = await _load_current(session, tenant.id, slug)
cfg = resolve_push_settings(snapshot)
if not cfg["url"]:
raise HTTPException(
status_code=400,
detail="推送 URL 未配置(租户配置与环境回退后均为空)"
"——请先在「配置中心 → 凭据」保存推送 URL")
return {"slug": slug, **await _probe_push(cfg)}
# ---- 平台设置(仅超管;resolver 回退链接入归 Phase 3) ----------------------------- # ---- 平台设置(仅超管;resolver 回退链接入归 Phase 3) -----------------------------
+177
View File
@@ -0,0 +1,177 @@
"""POST /api/platform/tenants/{slug}/email/test(Phase 3 Task 7c)——SMTP 测试邮件。
端点契约(计划 Task 7c):
- 用当前生效配置(resolve_email_settings:租户 email 组优先,空槽回退 env
SMTP_*)**真发一封**:主题 "[all-kefu] SMTP 测试邮件",正文含租户名与
时间,From = sender_name/user,To = body.to;
- SMTP 未配置(回退后 host/user 仍空)→ 400;
- 发送成败如实返回 {sent, error}(失败不 5xx——凭据对不对让收件箱与
错误串说话);密钥绝不进响应。
测试纪律: monkeypatch email_service.aiosmtplib.send 捕获报文与连接参数,
绝不真发(同 test_email_rendering 的 _capture_send 姿势,但打在
aiosmtplib.send 一层——端点经 send_test_email 走完整组包路径)。
"""
from datetime import datetime, timedelta, timezone
from app.engine.config import settings
from app.engine.services import email_service
from tests.test_tenant_admin_api import _auth, _bearer, _create, _tenant_admin_token
CONFIG = "/api/platform/tenants/{slug}/config"
EMAIL_TEST = "/api/platform/tenants/{slug}/email/test"
def _capture_smtp(monkeypatch):
"""aiosmtplib.send 桩: 捕获报文与连接参数,返回 True 语义(发送成功)。"""
captured = {}
async def fake_send(msg, **kwargs):
captured["msg"] = msg
captured["kwargs"] = kwargs
return msg
monkeypatch.setattr(email_service.aiosmtplib, "send", fake_send)
return captured
def _failing_smtp(monkeypatch, message: str):
async def failing_send(msg, **kwargs):
raise RuntimeError(message)
monkeypatch.setattr(email_service.aiosmtplib, "send", failing_send)
def _clear_smtp_env(monkeypatch):
"""env SMTP_* 清空(隔离本机 .env 联调值——同 test_push_settings_defaults
的隔离教训);"回退后仍空"的 400 分支才可控。"""
for key in ("SMTP_HOST", "SMTP_USER", "SMTP_PASS", "NOTIFY_EMAIL"):
monkeypatch.setattr(settings, key, "")
monkeypatch.setattr(settings, "SMTP_PORT", 465)
def _put_email_group(client, slug, payload):
return client.put(f"{CONFIG.format(slug=slug)}/email",
headers=_auth(client), json=payload)
def _post_test(client, slug, body, headers=None):
return client.post(EMAIL_TEST.format(slug=slug),
headers=headers or _auth(client), json=body)
SMTP_GROUP = {
"smtp": {"host": "t.smtp.example.com", "port": 587,
"user": "bot@demo.com", "password": "s3cret-pw"},
"sender_name": "Demo 发件人",
"notify_emails": ["notify@demo.com"],
}
def test_email_test_sends_with_tenant_config(super_client, monkeypatch):
"""已配置 SMTP: 真发路径走通——主题/收件人/发件人/正文(租户名+时间)
与连接参数(租户四字段)逐项钉死;密钥不进响应。"""
_create(super_client, slug="demo", name="Demo 建材")
assert _put_email_group(super_client, "demo", SMTP_GROUP).status_code == 200
captured = _capture_smtp(monkeypatch)
r = _post_test(super_client, "demo", {"to": "checker@example.com"})
assert r.status_code == 200, r.text
assert r.json() == {"slug": "demo", "sent": True, "error": None}
msg = captured["msg"]
assert str(msg["Subject"]) == "[all-kefu] SMTP 测试邮件"
assert str(msg["To"]) == "checker@example.com"
assert str(msg["From"]) == "【Demo 发件人】 <bot@demo.com>"
body = msg.get_payload(0).get_payload(decode=True).decode("utf-8")
assert "Demo 建材" in body # 正文含租户名
cst_today = datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d")
assert cst_today in body and "UTC+8" in body # 正文含时间(与推送同款 CST 口径)
# 连接参数 = 租户配置(与 email_service._send 同一连接逻辑)
assert captured["kwargs"] == {
"hostname": "t.smtp.example.com", "port": 587,
"username": "bot@demo.com", "password": "s3cret-pw", "use_tls": True}
assert "s3cret-pw" not in r.text # 密钥不进响应
def test_email_test_env_fallback(super_client, monkeypatch):
"""租户 smtp 未配置 → 回退 env SMTP_*(部署级平台默认同样可发)。"""
_create(super_client, slug="demo") # jiancai 档案: email 组无 smtp 键
for key, value in (("SMTP_HOST", "env.smtp.example.com"),
("SMTP_PORT", 465), ("SMTP_USER", "env-bot@x.com"),
("SMTP_PASS", "env-pass")):
monkeypatch.setattr(settings, key, value)
captured = _capture_smtp(monkeypatch)
r = _post_test(super_client, "demo", {"to": "checker@example.com"})
assert r.status_code == 200, r.text
assert r.json()["sent"] is True
assert captured["kwargs"]["hostname"] == "env.smtp.example.com"
assert captured["kwargs"]["password"] == "env-pass"
# sender_name 无租户覆盖 → 平台默认;user 走 env
assert str(captured["msg"]["From"]) == "【HESUPIPE】 <env-bot@x.com>"
def test_email_test_smtp_unconfigured_400(super_client, monkeypatch):
"""SMTP 未配置(租户组与 env 回退后仍空)→ 400 提示(不试探连接)。"""
_clear_smtp_env(monkeypatch)
_create(super_client, slug="demo")
r = _post_test(super_client, "demo", {"to": "checker@example.com"})
assert r.status_code == 400
detail = r.json()["detail"]
assert "SMTP" in detail and "未配置" in detail
def test_email_test_send_failure_reports_error(super_client, monkeypatch):
"""发送失败不 5xx: {sent: false, error 带原始错误串}如实返回。"""
_create(super_client, slug="demo")
assert _put_email_group(super_client, "demo", SMTP_GROUP).status_code == 200
_failing_smtp(monkeypatch, "smtp auth rejected")
r = _post_test(super_client, "demo", {"to": "checker@example.com"})
assert r.status_code == 200, r.text
body = r.json()
assert body["sent"] is False
assert "smtp auth rejected" in body["error"]
def test_email_test_validates_to(super_client, monkeypatch):
"""to 必填且须为含 @ 的字符串;未知字段 400(错误契约带字段路径)。"""
_clear_smtp_env(monkeypatch)
_create(super_client, slug="demo")
for bad in (None, 123, "", "no-at-sign", ["a@b.com"], {"to": "a@b.com"}):
r = _post_test(super_client, "demo", {"to": bad})
assert r.status_code == 400, f"{bad!r}: {r.text}"
assert "to" in r.json()["detail"]
# 缺 to / 未知字段
assert _post_test(super_client, "demo", {}).status_code == 400
r = _post_test(super_client, "demo", {"to": "a@b.com", "extra": 1})
assert r.status_code == 400
assert "extra" in r.json()["detail"]
def test_email_test_unknown_tenant_404(super_client, monkeypatch):
_clear_smtp_env(monkeypatch)
r = _post_test(super_client, "ghost", {"to": "a@b.com"})
assert r.status_code == 404
def test_email_test_auth_matrix(super_client, monkeypatch):
"""require_tenant(slug): 本租户管理员放行(400 是业务分支,鉴权先于
业务);跨租户 403;未认证 401。"""
_clear_smtp_env(monkeypatch)
_create(super_client, slug="jc2", admin="jc2-admin", password="jc2-pass-1")
_create(super_client, slug="au2", profile="auto",
admin="au2-admin", password="au2-pass-1")
token = _tenant_admin_token(super_client, "jc2-admin", "jc2-pass-1")
# 本租户管理员: 鉴权过,落在"未配置"业务分支 400(而非 403)
r = _post_test(super_client, "jc2", {"to": "a@b.com"}, headers=_bearer(token))
assert r.status_code == 400
# 别的租户 → 403
r = _post_test(super_client, "au2", {"to": "a@b.com"}, headers=_bearer(token))
assert r.status_code == 403
# 未认证 → 401
r = super_client.post(EMAIL_TEST.format(slug="jc2"), json={"to": "a@b.com"})
assert r.status_code == 401
+243
View File
@@ -0,0 +1,243 @@
"""POST /api/platform/tenants/{slug}/push/test(Phase 3 Task 7c)——推送连通性探测。
端点红线: **不发任何线索数据**——探测 body = {"messages": []}(空批量,
与 lead_push_service._do_post 同端点同头;UniService 是批量结构,空列表
对方案应为无操作)。测试断言钉死这一形态。
响应契约: {reachable, auth_ok(可空), status, reason, error}——
- 传输层异常 → reachable=false,reason 分类 dns/timeout/connect;
- HTTP 有响应 → reachable=true + status;2xx → auth_ok=true,
401/403 → false,其余(4xx/5xx)→ null(到达但状态异常,鉴权不定);
- 单次探测不重试(与 _run_push 的重试矩阵无关)。
测试纪律: monkeypatch config_routes.httpx.AsyncClient(假客户端),绝不真连。
"""
import socket
import httpx
from app.engine.config import settings
from app.platform.routes import config_routes
from tests.test_tenant_admin_api import _auth, _bearer, _create, _tenant_admin_token
CONFIG = "/api/platform/tenants/{slug}/config"
PUSH_TEST = "/api/platform/tenants/{slug}/push/test"
PUSH_GROUP = {
"enabled": True,
"url": "https://push.example/open/v1/inquiry/push",
"api_key": "k-t1",
"prefix": "demo-p",
"timeout": 5.5,
"max_attempts": 3,
"retry_backoff_base": 1,
}
def _install_fake_client(monkeypatch, handler):
"""config_routes.httpx.AsyncClient 桩: handler(call) → httpx.Response,
抛异常照抛。返回调用记录(init 构造参数 + 每次 post 的 url/json/headers)。"""
calls = []
class FakeClient:
def __init__(self, **kwargs):
calls.append({"init": kwargs})
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
async def post(self, url, json=None, headers=None):
call = {"url": url, "json": json, "headers": dict(headers or {})}
calls.append(call)
return handler(call)
monkeypatch.setattr(config_routes.httpx, "AsyncClient", FakeClient)
return calls
def _ok(call):
return httpx.Response(200, json={"success": True, "data": {
"accepted": 0, "duplicated": 0, "failed": []}})
def _put_push_group(client, slug, payload):
return client.put(f"{CONFIG.format(slug=slug)}/push",
headers=_auth(client), json=payload)
def _post_test(client, slug, headers=None):
return client.post(PUSH_TEST.format(slug=slug), headers=headers or _auth(client))
def _post_calls(calls):
return [c for c in calls if "url" in c]
def test_push_test_probe_shape_and_ok(super_client, monkeypatch):
"""正常路径: 200 → reachable+auth_ok;请求形态逐项钉死——同 _do_post 的
端点/X-Api-Key 头/超时/follow_redirects,body 恒为空 messages(红线)。"""
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200
calls = _install_fake_client(monkeypatch, _ok)
r = _post_test(super_client, "demo")
assert r.status_code == 200, r.text
assert r.json() == {"slug": "demo", "reachable": True, "auth_ok": True,
"status": 200, "reason": None, "error": None}
assert calls[0]["init"] == {"timeout": 5.5, "follow_redirects": True}
post = _post_calls(calls)[0]
assert post["url"] == "https://push.example/open/v1/inquiry/push"
assert post["json"] == {"messages": []} # 红线: 空批量,零线索数据
assert post["headers"] == {"X-Api-Key": "k-t1"} # 与 _do_post 同头
def test_push_test_auth_rejected(super_client, monkeypatch):
"""可达但鉴权失败: 401/403 → auth_ok=false(前端三态展示的第二态)。"""
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200
for status in (401, 403):
_install_fake_client(
monkeypatch, lambda call, s=status: httpx.Response(s))
r = _post_test(super_client, "demo")
assert r.status_code == 200
body = r.json()
assert body["reachable"] is True and body["status"] == status
assert body["auth_ok"] is False
def test_push_test_5xx_reachable_auth_unknown_single_shot(super_client, monkeypatch):
"""5xx: 可达但状态异常(auth_ok=null);探测单次发起,不重试。"""
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200
calls = _install_fake_client(
monkeypatch, lambda call: httpx.Response(502))
r = _post_test(super_client, "demo")
assert r.status_code == 200
body = r.json()
assert body["reachable"] is True and body["status"] == 502
assert body["auth_ok"] is None
assert len(_post_calls(calls)) == 1 # 无重试矩阵
def test_push_test_unreachable_dns(super_client, monkeypatch):
"""DNS 失败(异常 cause 链含 gaierror)→ reachable=false + reason=dns。"""
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200
def dns_fail(call):
raise httpx.ConnectError("Failed to resolve.") from socket.gaierror(
-2, "Name or service not known")
_install_fake_client(monkeypatch, dns_fail)
r = _post_test(super_client, "demo")
assert r.status_code == 200
body = r.json()
assert body["reachable"] is False and body["auth_ok"] is None
assert body["status"] is None
assert body["reason"] == "dns" and body["error"]
def test_push_test_unreachable_connect(super_client, monkeypatch):
"""连接拒绝/不可达(无 gaierror cause 的 ConnectError)→ reason=connect。"""
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200
def refused(call):
raise httpx.ConnectError("Connection refused")
_install_fake_client(monkeypatch, refused)
r = _post_test(super_client, "demo")
body = r.json()
assert body["reachable"] is False
assert body["reason"] == "connect"
def test_push_test_unreachable_dns_windows_text(super_client, monkeypatch):
"""Windows 实测形态(冒烟 Task 7c): getaddrinfo 失败被 httpx/anyio 包成
裸 ConnectError("[Errno 11001] getaddrinfo failed"),cause 链不带
gaierror——文案匹配兜底,仍须归 dns 而非 connect。"""
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200
def win_dns_fail(call):
raise httpx.ConnectError("[Errno 11001] getaddrinfo failed")
_install_fake_client(monkeypatch, win_dns_fail)
r = _post_test(super_client, "demo")
body = r.json()
assert body["reachable"] is False
assert body["reason"] == "dns"
assert "getaddrinfo" in body["error"]
def test_push_test_unreachable_timeout(super_client, monkeypatch):
"""连接超时(TimeoutException 族)→ reason=timeout。"""
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200
def timed_out(call):
raise httpx.ConnectTimeout("timed out")
_install_fake_client(monkeypatch, timed_out)
r = _post_test(super_client, "demo")
body = r.json()
assert body["reachable"] is False
assert body["reason"] == "timeout"
def test_push_test_url_unconfigured_400(super_client, monkeypatch):
"""URL 回退后仍空(租户 url 空槽 + env PUSH_API_URL 清空)→ 400。"""
monkeypatch.setattr(settings, "PUSH_API_URL", "")
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", {"prefix": "demo-p"}).status_code == 200
r = _post_test(super_client, "demo")
assert r.status_code == 400
detail = r.json()["detail"]
assert "URL" in detail and "未配置" in detail
def test_push_test_env_fallback(super_client, monkeypatch):
"""租户 push 组空槽 → env PUSH_* 回退(探测用的就是部署级平台默认)。"""
monkeypatch.setattr(settings, "PUSH_API_URL", "https://env-push.example/v1")
monkeypatch.setattr(settings, "PUSH_API_KEY", "env-key")
monkeypatch.setattr(settings, "PUSH_TIMEOUT_SECONDS", 8.0)
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", {"prefix": "demo-p"}).status_code == 200
calls = _install_fake_client(monkeypatch, _ok)
r = _post_test(super_client, "demo")
assert r.status_code == 200
assert r.json()["reachable"] is True
assert calls[0]["init"]["timeout"] == 8.0
post = _post_calls(calls)[0]
assert post["url"] == "https://env-push.example/v1"
assert post["headers"] == {"X-Api-Key": "env-key"}
def test_push_test_unknown_tenant_404(super_client, monkeypatch):
_install_fake_client(monkeypatch, _ok)
r = _post_test(super_client, "ghost")
assert r.status_code == 404
def test_push_test_auth_matrix(super_client, monkeypatch):
"""require_tenant(slug): 本租户管理员放行;跨租户 403;未认证 401。"""
_create(super_client, slug="jc2", admin="jc2-admin", password="jc2-pass-1")
_create(super_client, slug="au2", profile="auto",
admin="au2-admin", password="au2-pass-1")
assert _put_push_group(super_client, "jc2", PUSH_GROUP).status_code == 200
token = _tenant_admin_token(super_client, "jc2-admin", "jc2-pass-1")
_install_fake_client(monkeypatch, _ok)
# 本租户管理员放行
assert _post_test(super_client, "jc2", headers=_bearer(token)).status_code == 200
# 别的租户 → 403
assert _post_test(super_client, "au2", headers=_bearer(token)).status_code == 403
# 未认证 → 401
r = super_client.post(PUSH_TEST.format(slug="jc2"))
assert r.status_code == 401