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