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:
@@ -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
@@ -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
@@ -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']
|
||||
|
||||
@@ -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('拒绝')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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: '连接被拒绝或网络不可达'
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) -----------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user