fix(admin): es 尾巴空=整键丢弃(spec §3.2 管道身份泄漏修复)

审核页 es 收尾句留空的原语义「不触 es 池(保持快照原值)」是误读——
spec §3.2 要求整键丢弃:出厂 es 池带行业身份(如建材档案的
tuberías),保留快照会把上一行业的收尾句继续投给本租户的西语客户。

- model.ts:新增 applyWallDispatch 纯函数(wall 保存步载荷落地——
  非 null 语言覆写、null 语言整键删除);wallPoolDispatch/WallDispatch
  及三处旧「不触 es 池」注记全部改为整键丢弃语义
- index.vue:wall 步改用 applyWallDispatch;es 输入 placeholder 改
  「留空 = 丢弃现有 es 池」;进审核页后台拉取现 es 池,留空且有句时
  展示丢弃预览(破坏性操作不静默);skipAvatar 勾选保存中禁用
  (防 for-of 期间改动 saveSteps 计算源)
- 测试:原「es新句为null_整键删除」用例名实不符(断言的是保快照)
  已正名并移交载荷断言;新增 applyWallDispatch 两用例——tuberías
  泄漏链回归钉('es' in payload === false)+ 其余语言键透传

后端安全性已核实:boundary_filter 的 pools.get(lang) or pools["en"]
使缺 es 回退到新 EN 尾巴;wall_schema 仅必留 en/zh。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-20 22:51:30 +08:00
parent bd3e193f28
commit 348e0b5527
3 changed files with 155 additions and 20 deletions
@@ -14,10 +14,14 @@
* 版本含 avatar 文件名,读-改-写才不丢;wall 带池形态分派注记;每步 * 版本含 avatar 文件名,读-改-写才不丢;wall 带池形态分派注记;每步
* 含 label 供进度展示); * 含 label 供进度展示);
* - warningsLevel:规格判定=info / 剔除·数字移除=warning / 其他=info; * - warningsLevel:规格判定=info / 剔除·数字移除=warning / 其他=info;
* - isTerminal404:ApiError status 404 → 终态(任务过期,停止轮询)。 * - isTerminal404:ApiError status 404 → 终态(任务过期,停止轮询);
* - wallPoolDispatch / applyWallDispatch:池形态分派(单句→替换、多句→
* 追加)与载荷落地——es 尾巴 null = **整键丢弃** es 池(spec §3.2 防
* 出厂 es 池行业身份泄漏,如建材档案「tuberías」泄漏给非管道租户)。
*/ */
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
applyWallDispatch,
buildSaveSequence, buildSaveSequence,
dataUrlToBlob, dataUrlToBlob,
draftToBrandForm, draftToBrandForm,
@@ -233,9 +237,10 @@ describe('wallPoolDispatch', () => {
expect(d.pools.es).toEqual(['uno', 'dos', 'tail-es']) expect(d.pools.es).toEqual(['uno', 'dos', 'tail-es'])
}) })
it('es新句为null_整键删除', () => { it('es新句为null_分派标记null(payload落地=整键删除,见 applyWallDispatch)', () => {
// es 尾巴缺失 = 合法降级(spec §4):pools.es 为 null——消费方载荷 // es 尾巴缺失 = 合法降级:分派结果 pools.es 为 null——**语义是整键
// 不触 es 键(保持 GET 快照原值),「整键删除」指分派结果该键为 null // 丢弃**(spec §3.2:防出厂 es 池行业身份泄漏),不是「保持快照原值」。
// 载荷落地(键真的被删)由 applyWallDispatch 承担并在其用例断言。
const current = { en: ['a'], zh: ['中'], es: ['es-现句'] } const current = { en: ['a'], zh: ['中'], es: ['es-现句'] }
const d = wallPoolDispatch(current, { en: 'e', zh: 'z', es: null }) const d = wallPoolDispatch(current, { en: 'e', zh: 'z', es: null })
expect(d.mode).toBe('replace') expect(d.mode).toBe('replace')
@@ -263,3 +268,35 @@ describe('wallPoolDispatch', () => {
expect(d.pools.es).toBeNull() expect(d.pools.es).toBeNull()
}) })
}) })
// ---- applyWallDispatch(wall 保存步载荷落地) --------------------------------------------
describe('applyWallDispatch', () => {
it('es null = 整键删除(spec §3.2——出厂 es 池行业身份不外漏)', () => {
// 泄漏链回归钉:jiancai 出厂 es 池含「tuberías」(管道)——非管道
// 租户导入站点无西语内容时,es 键必须从载荷中消失(而非保留快照),
// 否则西语客户继续看到上一行业的收尾句。
const current = {
en: ['a'],
zh: ['中'],
es: ['Nuestro gerente te enviará el precio exacto a tu WhatsApp en breve. ¿Algo más sobre nuestras tuberías?']
}
const d = wallPoolDispatch(current, { en: 'e', zh: 'z', es: null })
const payload = applyWallDispatch(current, d)
// es 键不复存在(不是空数组、不是原句——键本身被删)
expect('es' in payload).toBe(false)
expect(payload).toEqual({ en: ['e'], zh: ['z'] })
})
it('非 null 语言覆写、其余语言键透传(快照 spread)', () => {
const current = { en: ['old-en'], zh: ['旧句一', '旧句二'], fr: ['bonjour'] }
const d = wallPoolDispatch(current, { en: 'new-en', zh: '新句', es: 'nuevo' })
const payload = applyWallDispatch(current, d)
expect(payload).toEqual({
en: ['new-en'],
zh: ['旧句一', '旧句二', '新句'],
es: ['nuevo'],
fr: ['bonjour'] // 快照其余语言键原样透传
})
})
})
+86 -10
View File
@@ -17,7 +17,8 @@
- 顶部黄条「保存将覆盖当前配置(产生新版本,可随时回滚)」+ warnings - 顶部黄条「保存将覆盖当前配置(产生新版本,可随时回滚)」+ warnings
分级区(warningsLevel:info 蓝 / warning 橙); 分级区(warningsLevel:info 蓝 / warning 橙);
- 左列品牌表单(draftToBrandForm 预填可改;头像 dataURL <img> 预览 + - 左列品牌表单(draftToBrandForm 预填可改;头像 dataURL <img> 预览 +
「不上传头像」勾选)+ 墙尾巴 en/zh/es 三行(es 留空 = 不修改该语言) 「不上传头像」勾选)+ 墙尾巴 en/zh/es 三行(es 留空 = 整键丢弃现有
es 池——spec §3.2 防出厂行业身份泄漏;现池有句时展示丢弃预览)
+ 联系方式两开关;右列四件套 tabs(等宽 textarea + UTF-8 字节计数); + 联系方式两开关;右列四件套 tabs(等宽 textarea + UTF-8 字节计数);
- 「保存全部」按 buildSaveSequence 依次执行(复用 ConfigCard 视觉壳): - 「保存全部」按 buildSaveSequence 依次执行(复用 ConfigCard 视觉壳):
头像(dataUrlToBlob→uploadBrandAvatar)→ brand → prompts → wall → 头像(dataUrlToBlob→uploadBrandAvatar)→ brand → prompts → wall →
@@ -197,7 +198,11 @@
<span v-else class="avatar-none">无</span> <span v-else class="avatar-none">无</span>
</div> </div>
<div class="avatar-side"> <div class="avatar-side">
<ElCheckbox v-if="brandForm.avatarDataUrl" v-model="skipAvatar"> <ElCheckbox
v-if="brandForm.avatarDataUrl"
v-model="skipAvatar"
:disabled="saveState === 'running'"
>
不上传头像(保留当前头像) 不上传头像(保留当前头像)
</ElCheckbox> </ElCheckbox>
<p v-else class="avatar-tip">草稿未抓到可用头像——保存不涉及头像</p> <p v-else class="avatar-tip">草稿未抓到可用头像——保存不涉及头像</p>
@@ -253,11 +258,25 @@
<ElInput v-model="wallTails.zh" placeholder="中文收尾句(必填)" /> <ElInput v-model="wallTails.zh" placeholder="中文收尾句(必填)" />
</ElFormItem> </ElFormItem>
<ElFormItem label="es"> <ElFormItem label="es">
<ElInput v-model="wallTails.es" placeholder="西班牙语收尾句(留空 = 不修改现有 es 池)" /> <div class="es-tail-col">
<ElInput
v-model="wallTails.es"
placeholder="西班牙语收尾句(留空 = 丢弃现有 es 池,西语客户将看到英文收尾句)"
/>
<div v-if="esDropPreview" class="es-drop-note">
<p>
保存后将丢弃现有 es 池({{ currentEsPool.length }} 句,西语客户将看到上方英文收尾句):
</p>
<ul>
<li v-for="(s, i) in currentEsPool" :key="i">{{ s }}</li>
</ul>
</div>
</div>
</ElFormItem> </ElFormItem>
</ElForm> </ElForm>
<p class="card-tip"> <p class="card-tip">
保存时按当前池形态分派:出厂单句池整句替换;已含人工编辑句(多句)的池追加到末项并提示。 保存时按当前池形态分派:出厂单句池整句替换;已含人工编辑句(多句)的池追加到末项并提示。es
留空 = 整键丢弃现有 es 池(西语会话回退英文收尾句)。
</p> </p>
</ConfigCard> </ConfigCard>
@@ -386,6 +405,7 @@
import ConfigCard from '@/views/config/center/ConfigCard.vue' import ConfigCard from '@/views/config/center/ConfigCard.vue'
import { PROMPTS_CAPS, formatKB, utf8Bytes } from '@/views/config/center/model' import { PROMPTS_CAPS, formatKB, utf8Bytes } from '@/views/config/center/model'
import { import {
applyWallDispatch,
buildSaveSequence, buildSaveSequence,
dataUrlToBlob, dataUrlToBlob,
draftToBrandForm, draftToBrandForm,
@@ -632,6 +652,33 @@
const wallTails = ref({ en: '', zh: '', es: '' }) const wallTails = ref({ en: '', zh: '', es: '' })
const contactForm = ref({ ccCompletion: false, emailLightAsk: false }) const contactForm = ref({ ccCompletion: false, emailLightAsk: false })
const skipAvatar = ref(false) const skipAvatar = ref(false)
/** 现租户 es 池(进审核页时后台拉取——es 留空的丢弃预览数据源) */
const currentEsPool = ref<string[]>([])
/** es 留空且现池有句 → 展示将被丢弃的句子(spec §3.2 整键丢弃是破坏性
* 操作,对多句人工池不静默——预览让管理员看清丢的是什么) */
const esDropPreview = computed(
() => wallTails.value.es.trim() === '' && currentEsPool.value.length > 0
)
/** 后台拉取现租户 es 池(静默失败为空——预览缺失不阻塞审核);完成后
* 校验 slug 未变,防跨租户串台(关闭重开的竞态窗口) */
function loadCurrentEsPool(slug: string): void {
fetchTenantConfig(slug, { showErrorMessage: false })
.then((snap) => {
if (targetSlug.value !== slug) return
const group = snap.config.wall as Record<string, unknown> | undefined
const pools = group?.deflections_contact as Record<string, unknown> | undefined
const es = pools?.es
currentEsPool.value = Array.isArray(es)
? es.filter((s): s is string => typeof s === 'string')
: []
})
.catch(() => {
if (targetSlug.value !== slug) return
currentEsPool.value = []
})
}
function blankBrandForm(): SiteImportBrandForm { function blankBrandForm(): SiteImportBrandForm {
return { return {
@@ -656,6 +703,7 @@
wallTails.value = { en: '', zh: '', es: '' } wallTails.value = { en: '', zh: '', es: '' }
contactForm.value = { ccCompletion: false, emailLightAsk: false } contactForm.value = { ccCompletion: false, emailLightAsk: false }
skipAvatar.value = false skipAvatar.value = false
currentEsPool.value = []
saveState.value = 'idle' saveState.value = 'idle'
completedKinds.value = [] completedKinds.value = []
saveErrorText.value = '' saveErrorText.value = ''
@@ -675,6 +723,7 @@
} }
importVisible.value = false importVisible.value = false
reviewVisible.value = true reviewVisible.value = true
loadCurrentEsPool(targetSlug.value)
} }
function byteInfo(tab: PromptsTab): { over: boolean; label: string } { function byteInfo(tab: PromptsTab): { over: boolean; label: string } {
@@ -746,7 +795,7 @@
return '主题色须为 3 或 6 位十六进制(如 #066fc0),或留空取挂件默认' return '主题色须为 3 或 6 位十六进制(如 #066fc0),或留空取挂件默认'
} }
if (!wallTails.value.en.trim() || !wallTails.value.zh.trim()) { if (!wallTails.value.en.trim() || !wallTails.value.zh.trim()) {
return '墙尾巴 en / zh 不能为空(es 可留空 = 不修改该语言池)' return '墙尾巴 en / zh 不能为空(es 可留空 = 丢弃现有 es 池)'
} }
for (const tab of promptsTabs.value) { for (const tab of promptsTabs.value) {
if (utf8Bytes(tab.text) > PROMPTS_CAPS[tab.key]) { if (utf8Bytes(tab.text) > PROMPTS_CAPS[tab.key]) {
@@ -814,11 +863,7 @@
zh: wallTails.value.zh.trim(), zh: wallTails.value.zh.trim(),
es: wallTails.value.es.trim() === '' ? null : wallTails.value.es.trim() es: wallTails.value.es.trim() === '' ? null : wallTails.value.es.trim()
}) })
const nextPool: Record<string, string[]> = { ...current } const nextPool = applyWallDispatch(current, dispatch)
for (const lang of ['en', 'zh', 'es'] as const) {
const pool = dispatch.pools[lang]
if (pool !== null) nextPool[lang] = pool
}
await putConfigGroup( await putConfigGroup(
slug, slug,
'wall', 'wall',
@@ -1125,6 +1170,37 @@
color: var(--art-gray-400); color: var(--art-gray-400);
} }
.es-tail-col {
display: flex;
flex-direction: column;
width: 100%;
gap: 6px;
}
.es-drop-note {
padding: 8px 10px;
font-size: 12px;
line-height: 1.6;
color: var(--el-color-warning);
background: var(--el-color-warning-light-9);
border: 1px solid var(--el-color-warning-light-5);
border-radius: 4px;
p {
margin: 0 0 4px;
}
ul {
margin: 0;
padding-left: 18px;
li {
margin: 2px 0;
word-break: break-all;
}
}
}
.switch-row { .switch-row {
display: flex; display: flex;
gap: 10px; gap: 10px;
+28 -6
View File
@@ -46,7 +46,8 @@ export interface SiteImportDraft {
evaluator: string evaluator: string
} }
/** 墙尾巴三语(deflections_contact 池的已留资态收尾句);es 可 null /** 墙尾巴三语(deflections_contact 池的已留资态收尾句);es 可 null
* (合法降级——wall_schema 只必留 en/zh,保存时不触 es 池) */ * (合法降级——保存时整键丢弃 es 池,spec §3.2:防出厂 es 池行业身份
* 泄漏;西语会话由 boundary_filter 回退看到新 EN 尾巴) */
wall_tails: { en: string; zh: string; es: string | null } wall_tails: { en: string; zh: string; es: string | null }
contact: { cc_completion: boolean; email_light_ask: boolean } contact: { cc_completion: boolean; email_light_ask: boolean }
/** 白话注记清单(规格判定/剔除/移除/缺省兜底)——分级见 warningsLevel */ /** 白话注记清单(规格判定/剔除/移除/缺省兜底)——分级见 warningsLevel */
@@ -183,7 +184,7 @@ export interface SaveStep {
* ——上传头像会写新版本(brand.avatar 落文件名),之后再读-改-写才不把 * ——上传头像会写新版本(brand.avatar 落文件名),之后再读-改-写才不把
* 文件名丢掉(spec §5)。wall 步的池形态分派:deflections_contact 出厂 * 文件名丢掉(spec §5)。wall 步的池形态分派:deflections_contact 出厂
* 单句 → 整句替换;已多句 → 追加(以保存时 GET 快照为准,es 尾巴 null * 单句 → 整句替换;已多句 → 追加(以保存时 GET 快照为准,es 尾巴 null
* 则不触 es 池)。 * 则整键丢弃 es 池——spec §3.2)。
*/ */
export function buildSaveSequence(draft: SiteImportDraft): SaveStep[] { export function buildSaveSequence(draft: SiteImportDraft): SaveStep[] {
const steps: SaveStep[] = [] const steps: SaveStep[] = []
@@ -208,7 +209,7 @@ export function buildSaveSequence(draft: SiteImportDraft): SaveStep[] {
{ {
kind: 'wall', kind: 'wall',
label: '安全词表(墙尾巴)', label: '安全词表(墙尾巴)',
note: 'GET config/wall 读-改-写:deflections_contact 按池形态分派——单句→替换、多句→追加(es 尾巴 null 不触 es 池),其余键透传' note: 'GET config/wall 读-改-写:deflections_contact 按池形态分派——单句→替换、多句→追加(es 尾巴 null = 整键丢弃 es 池,spec §3.2),其余键透传'
}, },
{ {
kind: 'contact', kind: 'contact',
@@ -222,8 +223,11 @@ export function buildSaveSequence(draft: SiteImportDraft): SaveStep[] {
// ---- 墙池形态分派(Task 9——保存时以新鲜 GET 快照判断替换/追加) ------------------------ // ---- 墙池形态分派(Task 9——保存时以新鲜 GET 快照判断替换/追加) ------------------------
/** 分派结果:mode=append 时 UI 提示「原池含人工编辑句,已追加而非替换」; /** 分派结果:mode=append 时 UI 提示「原池含人工编辑句,已追加而非替换」;
* pools 按语言给出目标池,null = 不触该语言(es 尾巴缺失的合法降级, * pools 按语言给出目标池,null = 该语言**整键丢弃**(es 尾巴缺失的合法
* 载荷保持 GET 快照原值) */ * 降级——spec §3.2:防出厂 es 池的行业身份泄漏,如建材档案「tuberías」
* 泄漏给非管道租户的西语客户;丢弃后西语会话由 boundary_filter 的
* `pools.get(lang) or pools["en"]` 回退看到新 EN 尾巴,wall_schema 仅
* 必留 en/zh,缺 es 编译/运行双安全) */
export interface WallDispatch { export interface WallDispatch {
mode: 'replace' | 'append' mode: 'replace' | 'append'
pools: Record<'en' | 'zh' | 'es', string[] | null> pools: Record<'en' | 'zh' | 'es', string[] | null>
@@ -241,7 +245,7 @@ function asStringList(value: unknown): string[] {
* 保存时以**新鲜 GET** 的现池为判断基准,逐语言分派—— * 保存时以**新鲜 GET** 的现池为判断基准,逐语言分派——
* - 池内 >1 句 = 含人工编辑句 → 追加到末项(原句保留); * - 池内 >1 句 = 含人工编辑句 → 追加到末项(原句保留);
* - ≤1 句(出厂单句/空/缺键)→ 整句替换为新尾巴; * - ≤1 句(出厂单句/空/缺键)→ 整句替换为新尾巴;
* - es 新尾巴 null → pools.es 为 null(不触该语言池)。 * - es 新尾巴 null → pools.es 为 null(**整键丢弃**,见 WallDispatch 注)。
* 任一语言走了追加 → mode='append'(调用方展示追加提示文案)。 * 任一语言走了追加 → mode='append'(调用方展示追加提示文案)。
*/ */
export function wallPoolDispatch( export function wallPoolDispatch(
@@ -268,6 +272,24 @@ export function wallPoolDispatch(
return { mode: appended ? 'append' : 'replace', pools } return { mode: appended ? 'append' : 'replace', pools }
} }
/**
* 分派结果 → deflections_contact 目标池(wall 保存步载荷):非 null 语言
* 覆写、null 语言**整键删除**(实践上仅 es——en/zh 尾巴必填非空)。其
* 余语言键原样透传(快照 spread)。
*/
export function applyWallDispatch(
currentPools: Record<string, string[]>,
dispatch: WallDispatch
): Record<string, string[]> {
const nextPool: Record<string, string[]> = { ...(currentPools ?? {}) }
for (const lang of ['en', 'zh', 'es'] as const) {
const pool = dispatch.pools[lang]
if (pool === null) delete nextPool[lang]
else nextPool[lang] = pool
}
return nextPool
}
// ---- warnings 分级 ------------------------------------------------------------------- // ---- warnings 分级 -------------------------------------------------------------------
/** /**