fix(client): reject empty imported brand rules

This commit is contained in:
WuYongJie 2026-11-12 17:44:13 +08:00
parent b21f34ad98
commit 2156598a2d
2 changed files with 37 additions and 10 deletions

View File

@ -28,6 +28,7 @@ function markdownSections(text) {
}
const excludedSection = title => /人物|模特|素材索引|资源索引|主图|图库|头像/i.test(title);
const brandRule = /品牌风格|品牌调性|视觉风格|视觉规范|色彩|排版|提示词|验收/;
function selectSections(text, include) {
const levels = [];
const selected = [];
@ -37,11 +38,13 @@ function selectSections(text, include) {
const excluded = Boolean(parent?.excluded) || excludedSection(section.title);
const allowed = !excluded && (include(section.title) || Boolean(parent?.allowed));
levels[section.level] = { allowed, excluded };
if (allowed) selected.push(section.lines.join('\n').trim());
if (allowed) selected.push(section);
}
return selected.filter(Boolean).join('\n\n');
return selected;
}
const sectionText = sections => sections.map(section => section.lines.join('\n').trim()).join('\n\n');
function brandSection(text, name) {
const sections = markdownSections(text);
const start = sections.findIndex(section => section.title === name);
@ -51,7 +54,7 @@ function brandSection(text, name) {
// An exact shop heading can carry a directly labelled tone before any
// subheading. Only explicitly identified rule lines are admitted here.
const intro = sections[start].lines.slice(1).filter(line =>
/品牌风格|品牌调性|视觉风格|视觉规范|色彩|排版|提示词|验收/.test(line) && !excludedSection(line));
brandRule.test(line) && !excludedSection(line));
if (intro.length) result.push(`#### 品牌视觉规范\n${intro.join('\n')}`);
for (const section of sections.slice(start + 1)) {
if (section.level <= level) break;
@ -80,11 +83,18 @@ export async function importEnterpriseRules(platform, sourceRoot) {
for (const shop of platform.shops) {
const name = shop.name;
if (!markdownSections(styles).some(section => section.title === name)) continue;
const sourceFile = name === '顾家' ? 'references/gujia-visual-style.md' : 'references/brand-style-prompts.md';
const source = name === '顾家' ? gujia : brandSection(styles, name);
const text = selectSections(source, title => /品牌风格|品牌调性|视觉风格|视觉规范|色彩|排版|提示词|验收/.test(title));
const sections = selectSections(source, title => brandRule.test(title));
if (!sections.some(({ title, lines }) =>
(brandRule.test(title) && /[::]\s*\S/.test(title)) || lines.slice(1).some(line => line.trim())
)) {
throw Object.assign(Error(`品牌「${name}」的规则来源 ${sourceFile} 缺少可导入的规范内容`), { code: 'RULE_BRAND_SOURCE' });
}
const text = sectionText(sections);
const tone = text.match(/品牌(?:风格|调性)[::]([^\n]+)/)?.[1]?.trim() || '';
const brandId = `enterprise:${shop.id}`;
catalog.brands.push({ id: brandId, name, revision: sha(text), source: `enterprise-knowledge:${name === '顾家' ? 'references/gujia-visual-style.md' : 'references/brand-style-prompts.md'}`,
catalog.brands.push({ id: brandId, name, revision: sha(text), source: `enterprise-knowledge:${sourceFile}`,
profile: { name, tone, fontFallbacks: [...FONT_FALLBACKS], visualRules: Object.fromEntries(chunks(text).map((part, i) => [`sourcePart${i + 1}`, part])),
adaptation: '原文规范离线迁入;字体按候选顺序回退,实际可用性取决于运行环境,不宣称已移植源PS模板字体。' } });
catalog.shops.push({ ...shop, brandId, revision: digest(shop), source: `material-platform:categories:${shop.id}`, platform: 'material-library', rules: [`当前素材库店铺为「${shop.name}」,店铺ID为${shop.id}。只使用此店铺的规范;渠道与售后政策未提供,不得自行补造。`] });
@ -98,7 +108,7 @@ export async function importEnterpriseRules(platform, sourceRoot) {
const guide = await read('template-adapter/' + template.usage_guide);
const mapping = JSON.parse(await read('template-adapter/' + template.mapping_asset_path));
if (!template.template_id || !template.brand || !template.category || mapping.brand !== template.brand || mapping.category !== template.category || mapping.template_id !== template.template_id) throw Object.assign(Error('模板登记与映射范围不一致,未建立绑定'), { code: 'RULE_MAPPING_IDENTITY' });
const relevant = selectSections(guide, title => /屏号.*固定|固定.*元素|详情.*结构|页面.*结构/.test(title));
const relevant = sectionText(selectSections(guide, title => /屏号.*固定|固定.*元素|详情.*结构|页面.*结构/.test(title)));
if (!Array.isArray(mapping.screens) || !mapping.screens.length || mapping.screens.some(s => !s.screen_id)) throw Object.assign(Error('模板缺少真实屏幕映射,未建立绑定'), { code: 'RULE_MAPPING_IDENTITY' });
const screens = mapping.screens.map(s => ({ id: s.screen_id, title: s.title, purpose: s.purpose, role: s.page_role }));
const rules = [`当前品类模板:${template.template_name}(${template.template_id},映射版本${template.mapping_version})。`,

View File

@ -29,21 +29,38 @@ test('enterprise import uses exact real catalog identities and sourced category
assert.throws(() => resolveRules({ brandId: 'enterprise:s1', shopId: 's1', categoryId: 'c2' }, c), /尚未配置/);
assert.throws(() => resolveRules({ brandId: 'enterprise:s2', shopId: 's2', categoryId: 'c1' }, c), /尚未配置/);
const before = JSON.stringify(a);
await f.write('references/gujia-visual-style.md', '## 品牌风格:新的温暖风格\n## 人物素材索引\n索引\n## 提示词与验收\n新验收规范\n');
await f.write('references/gujia-visual-style.md', '## 品牌调性:新的温暖风格\n## 视觉规范\n新验收规范\n');
const next = await importEnterpriseRules(f.platform, f.root);
assert.equal(next.brands.find(brand => brand.name === '顾家').profile.tone, '新的温暖风格');
assert.notEqual(next.revision, c.revision);
assert.notEqual(resolveRules({ brandId: 'enterprise:s1', shopId: 's1', categoryId: 'c1' }, next).version, a.version);
assert.equal(JSON.stringify(a), before);
assert.ok(c.sourceFiles.every(s => /^[a-f0-9]{64}$/.test(s.sha256)));
} finally { await cleanup(f.dir); }
});
test('missing or escaped knowledge sources fail closed, with no fixture fallback', async () => {
test('a source outside the authorized directory is rejected', async () => {
const f = await fixture();
try {
await fs.writeFile(path.join(f.dir, 'outside.json'), '{}');
await f.write('template-adapter/references/template-catalog.json', JSON.stringify({ templates: [{ ...f.template, mapping_asset_path: '../../outside.json' }] }));
await assert.rejects(importEnterpriseRules(f.platform, f.root), /超出授权目录/);
await f.write('references/gujia-visual-style.md', '不再符合规范结构');
await assert.rejects(importEnterpriseRules(f.platform, f.root), /来源结构已改变/);
} finally { await cleanup(f.dir); }
});
test('a missing source is rejected without substituting default rules', async () => {
const f = await fixture();
try {
await fs.unlink(path.join(f.root, 'references/gujia-visual-style.md'));
await assert.rejects(importEnterpriseRules(f.platform, f.root), { code: 'ENOENT' });
} finally { await cleanup(f.dir); }
});
test('a registered brand must have rule content in a recognized section', async () => {
const f = await fixture();
try {
for (const text of ['不再符合规范结构', '## 品牌风格\n', '## 人物素材索引\n### 视觉规范\n不属于品牌规则']) {
await f.write('references/gujia-visual-style.md', text);
await assert.rejects(importEnterpriseRules(f.platform, f.root), { code: 'RULE_BRAND_SOURCE' });
}
} finally { await cleanup(f.dir); }
});