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
+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: '连接被拒绝或网络不可达'
}