feat(admin): contact/engine/misc 表单映射纯函数

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-14 20:03:09 +08:00
parent 8b430565d6
commit 4dd74d6682
2 changed files with 212 additions and 0 deletions
@@ -36,12 +36,18 @@ import {
buildWallPayload,
COMPARE_SCOPE_ALL,
collectRemovedWallVariables,
contactToForm,
contactToPayload,
descendingVersions,
engineToForm,
engineToPayload,
formatKB,
formatVersionTime,
initWallEdit,
JSON_GROUP_FIELDS,
jsonGroupDraft,
miscToForm,
miscToPayload,
normalizeAiCredsGroup,
normalizeEmailCredsGroup,
normalizePromptsGroup,
@@ -63,6 +69,8 @@ import {
touchstoneSentences,
utf8Bytes,
validateEmailCredsForm,
validateEngineForm,
validateKeyValueRows,
validatePushCredsForm,
type EmailCredsForm,
type PromptsDraft,
@@ -830,3 +838,62 @@ describe('WALL_SCENARIOS(场景卡定义)', () => {
expect(SCENARIO_WHEN_LABELS).toEqual({ not_given: '客户还没留联系方式时', given: '客户已经留过之后' })
})
})
// ---- contact / engine / misc 表单化(Task 12;JsonGroup 退役配套) ---------------------
describe('contact 表单化', () => {
it('快照 → 表单(bool 缺省 false / 语言数组)', () => {
expect(contactToForm({ cc_completion: true, email_light_ask: false, template_languages: ['en', 'zh'] }))
.toEqual({ ccCompletion: true, emailLightAsk: false, templateLanguages: ['en', 'zh'] })
expect(contactToForm(null)).toEqual({ ccCompletion: false, emailLightAsk: false, templateLanguages: [] })
})
it('表单 → payload:接管键覆写 + 未知键透传', () => {
const snapshot = { cc_completion: false, email_light_ask: false, template_languages: [], _meta: 'keep' }
expect(contactToPayload({ ccCompletion: true, emailLightAsk: true, templateLanguages: ['en', ' '] }, snapshot))
.toEqual({ _meta: 'keep', cc_completion: true, email_light_ask: true, template_languages: ['en'] })
})
})
describe('engine 表单化', () => {
it('快照 → 表单(数值转串 / seed 映射转行)', () => {
expect(engineToForm({ rate_limit_per_hour: 120, ws_conn_limit: 5, context_seed_keys: { a: 'b' } }))
.toEqual({ rateLimit: '120', wsConnLimit: '5', seedRows: [{ key: 'a', value: 'b' }] })
expect(engineToForm(null)).toEqual({ rateLimit: '', wsConnLimit: '', seedRows: [] })
})
it('payload:空数值槽保快照原值(快照也无 → 键省略);未知键透传;seed 恒重建', () => {
const snapshot = { rate_limit_per_hour: 120, ws_conn_limit: 5, other: 1 }
expect(engineToPayload({ rateLimit: '', wsConnLimit: '9', seedRows: [] }, snapshot))
.toEqual({ other: 1, rate_limit_per_hour: 120, ws_conn_limit: 9, context_seed_keys: {} })
expect(engineToPayload({ rateLimit: '60', wsConnLimit: '', seedRows: [] }, null))
.toEqual({ rate_limit_per_hour: 60, context_seed_keys: {} })
})
it('payload:seed 行空键值剔除、重建映射', () => {
expect(engineToPayload(
{ rateLimit: '1', wsConnLimit: '1', seedRows: [{ key: 'product_name', value: 'app_product_name' }, { key: '', value: 'x' }] },
{}
).context_seed_keys).toEqual({ product_name: 'app_product_name' })
})
it('校验:非正整数报错', () => {
expect(validateEngineForm({ rateLimit: '0', wsConnLimit: '', seedRows: [] }))
.toEqual(['每 IP 每小时发言上限:须为正整数'])
expect(validateEngineForm({ rateLimit: '30', wsConnLimit: '2', seedRows: [] })).toEqual([])
})
})
describe('misc 表单化', () => {
it('往返:快照 → 表单 → payload 保未知键', () => {
const snapshot = { name_to_region_extras: { 'saudi arabia': '966' }, keep: true }
const form = miscToForm(snapshot)
expect(form.extrasRows).toEqual([{ key: 'saudi arabia', value: '966' }])
expect(miscToPayload({ extrasRows: [{ key: 'saudi arabia', value: '966' }, { key: 'uae', value: '971' }] }, snapshot))
.toEqual({ keep: true, name_to_region_extras: { 'saudi arabia': '966', uae: '971' } })
})
})
describe('KeyValue 行校验(engine seed / misc extras 共用)', () => {
it('重复键与空键报错', () => {
expect(validateKeyValueRows([{ key: 'a', value: '1' }, { key: 'a', value: '2' }], '映射'))
.toEqual(['映射:键 a 重复'])
expect(validateKeyValueRows([{ key: '', value: '1' }], '映射')).toEqual([])
})
})
+145
View File
@@ -934,3 +934,148 @@ export const JSON_GROUP_FIELDS: Record<
}
]
}
// ---- contact / engine / misc 表单化(spec §5.4-5.7;JsonGroup 退役配套) ----------------
export interface KeyValueRow {
key: string
value: string
}
/** 键值行校验(空行整行忽略;非空键重复报错;半行序列化时剔除不报错) */
export function validateKeyValueRows(rows: KeyValueRow[], label: string): string[] {
const seen = new Set<string>()
const errors: string[] = []
for (const row of rows) {
if (row.key.trim() === '' && row.value.trim() === '') continue
const key = row.key.trim()
if (key === '') continue // 半行(有 value 无 key)序列化时整行剔除,不报错
if (seen.has(key)) errors.push(`${label}:键 ${key} 重复`)
seen.add(key)
}
return errors
}
function rowsToRecord(rows: KeyValueRow[]): Record<string, string> {
const out: Record<string, string> = {}
for (const row of rows) {
const key = row.key.trim()
if (key !== '') out[key] = row.value
}
return out
}
function recordToRows(value: unknown): KeyValueRow[] {
const src = asRecord(value) ?? {}
return Object.entries(src)
.filter(([, v]) => typeof v === 'string')
.map(([key, v]) => ({ key, value: v as string }))
}
// ---- contact ------------------------------------------------------------------------
export interface ContactForm {
ccCompletion: boolean
emailLightAsk: boolean
templateLanguages: string[]
}
export function contactToForm(
group: Record<string, unknown> | null | undefined
): ContactForm {
const src = group ?? {}
return {
ccCompletion: src.cc_completion === true,
emailLightAsk: src.email_light_ask === true,
templateLanguages: strList(src.template_languages)
}
}
export function contactToPayload(
form: ContactForm,
group: Record<string, unknown> | null | undefined
): Record<string, unknown> {
return {
...(group ?? {}),
cc_completion: form.ccCompletion,
email_light_ask: form.emailLightAsk,
template_languages: form.templateLanguages.map((s) => s.trim()).filter(Boolean)
}
}
// ---- engine -------------------------------------------------------------------------
export interface EngineForm {
/** 正整数串;'' = 未填(payload 保快照原值/省略键——绝不提交非法值) */
rateLimit: string
wsConnLimit: string
seedRows: KeyValueRow[]
}
function intToForm(value: unknown): string {
return typeof value === 'number' && Number.isInteger(value) ? String(value) : ''
}
export function engineToForm(
group: Record<string, unknown> | null | undefined
): EngineForm {
const src = group ?? {}
return {
rateLimit: intToForm(src.rate_limit_per_hour),
wsConnLimit: intToForm(src.ws_conn_limit),
seedRows: recordToRows(src.context_seed_keys)
}
}
/**
* 表单 → payload:空数值槽 = 保快照原值(spread 已带上;快照无值则不带键
* ——resolver 出厂回退),非空槽覆写;context_seed_keys 行编辑恒重建(空行
* 剔除——用户显式管理该映射,清空即提交空映射);其余未知键透传。
*/
export function engineToPayload(
form: EngineForm,
group: Record<string, unknown> | null | undefined
): Record<string, unknown> {
const out: Record<string, unknown> = { ...(group ?? {}) }
const rate = form.rateLimit.trim()
if (rate !== '') out.rate_limit_per_hour = Number(rate)
const ws = form.wsConnLimit.trim()
if (ws !== '') out.ws_conn_limit = Number(ws)
out.context_seed_keys = rowsToRecord(form.seedRows)
return out
}
export function validateEngineForm(form: EngineForm): string[] {
const errors: string[] = []
const rate = form.rateLimit.trim()
if (rate !== '' && (!/^\d+$/.test(rate) || Number(rate) < 1)) {
errors.push('每 IP 每小时发言上限:须为正整数')
}
const ws = form.wsConnLimit.trim()
if (ws !== '' && (!/^\d+$/.test(ws) || Number(ws) < 1)) {
errors.push('每 IP WebSocket 连接上限:须为正整数')
}
return errors
}
// ---- misc ---------------------------------------------------------------------------
export interface MiscForm {
extrasRows: KeyValueRow[]
}
export function miscToForm(
group: Record<string, unknown> | null | undefined
): MiscForm {
return { extrasRows: recordToRows((group ?? {}).name_to_region_extras) }
}
export function miscToPayload(
form: MiscForm,
group: Record<string, unknown> | null | undefined
): Record<string, unknown> {
return {
...(group ?? {}),
name_to_region_extras: rowsToRecord(form.extrasRows)
}
}