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: '连接被拒绝或网络不可达'
}
+45 -5
View File
@@ -1,5 +1,6 @@
import html
import logging
from datetime import datetime, timedelta, timezone
from email.mime.multipart import MIMEMultipart
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
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:
if not all([cfg["host"], cfg["user"], cfg["notify_emails"]]):
logger.warning("SMTP not configured, skipping email")
return False
try:
await aiosmtplib.send(
msg, hostname=cfg["host"], port=cfg["port"],
username=cfg["user"], password=cfg["password"],
use_tls=True,
)
await _smtp_send(msg, cfg)
logger.info("Lead email sent to %s", cfg["notify_emails"])
return True
except Exception:
@@ -170,3 +178,35 @@ async def send_correction_email(
f"<table>{_contact_rows(body_contacts)}</table>"
f"</body></html>", "html", "utf-8"))
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 版本**
(reset/编辑器的预览入口)。判定优先级与 conversation.py 执行侧一致:
价格墙 → 零号码墙 → 再索要墙(仅 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,仅超管。本阶段只落
存储与接口(value 以 JSON Text 直存,读回还原类型);resolver 的平台默认
回退链接入归 Phase 3。
@@ -36,12 +40,15 @@ email.smtp.password 及平台设置的密钥名值,所有携带 config/value 的
"""
import copy
import json
import socket
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
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.platform.auth import UserContext, require_super
from app.platform.config_validation import (
@@ -49,6 +56,10 @@ from app.platform.config_validation import (
ValidationContext,
validate_group,
)
from app.platform.config_views import (
resolve_email_settings,
resolve_push_settings,
)
from app.platform.db import (
PlatformSetting,
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}
# ---- 凭据测试端点(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) -----------------------------
+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