feat: add vocabulary handbook import pipeline

This commit is contained in:
Codex
2026-06-22 00:11:01 +08:00
parent c1159f8745
commit b458317a81
13 changed files with 2448 additions and 186 deletions

View File

@@ -802,6 +802,231 @@ async function testTenantContentAssetsAndImports() {
'catalog should expose imported question through the new collection binding',
);
const vocabEntry = await request('/api/tenant-content/content-entries', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryKey: 'integration-vocabulary',
regionId: ids.region,
name: '集成测试背单词入口',
entryType: 'vocabulary',
route: '/vocabulary',
order: 21,
},
});
const vocabRoot = await request('/api/tenant-content/content-nodes', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryId: vocabEntry.item.id,
regionId: ids.region,
nodeKey: 'integration-vocabulary-root',
name: '英语核心词',
nodeType: 'category',
isLeaf: false,
},
});
const deniedVocabularyPreview = await request('/api/tenant-content/imports/preview/vocabulary', {
method: 'POST',
body: {
units: [{ name: '学生不能导入单词', words: [{ word: 'deny', meaning: '拒绝' }] }],
},
expectStatus: 403,
});
assert.equal(deniedVocabularyPreview.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not preview vocabulary import');
const invalidVocabularyPreview = await request('/api/tenant-content/imports/preview/vocabulary', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceName: 'invalid-vocabulary-import.json',
entryId: vocabEntry.item.id,
contentNodeId: vocabRoot.item.id,
vocabulary_units_示例数据: [
{ legacyId: 'integration-vocab-invalid-unit', name: '旧格式错误单词单元', order: 1 },
],
vocabulary_示例数据: [
{ unitId: 'integration-vocab-invalid-unit', word: '', meaning: '', order: 1 },
],
},
});
assert.ok(invalidVocabularyPreview.issues?.some(issue => issue.code === 'VOCABULARY_WORD_REQUIRED'), 'invalid vocabulary preview should validate word');
assert.ok(invalidVocabularyPreview.issues?.some(issue => issue.code === 'VOCABULARY_MEANING_REQUIRED'), 'invalid vocabulary preview should validate meaning');
const rejectedVocabularyImport = await request('/api/tenant-content/imports/vocabulary', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: invalidVocabularyPreview.job.id },
expectStatus: 409,
});
assert.equal(rejectedVocabularyImport.code, 'IMPORT_HAS_ERRORS', 'invalid vocabulary import should be rejected');
const vocabularyPreview = await request('/api/tenant-content/imports/preview/vocabulary', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceName: 'legacy-vocabulary-import.json',
regionId: ids.region,
entryId: vocabEntry.item.id,
contentNodeId: vocabRoot.item.id,
vocabulary_units_示例数据: [
{
legacyId: 'integration-vocab-unit-001',
name: 'Unit 1 - 高频核心词',
description: '旧模板字段导入验证',
order: 1,
isActive: true,
},
],
vocabulary_示例数据: [
{
legacyId: 'integration-vocab-word-abandon',
unitId: 'integration-vocab-unit-001',
word: 'abandon',
phonetic: '/əˈbændən/',
meaning: 'v. 放弃,抛弃',
example: 'He had to abandon his car in the snow.',
exampleTranslation: '他不得不把车丢弃在雪地里。',
difficulty: 3,
tags: ['高频词', '考纲核心'],
order: 1,
isActive: true,
},
],
},
});
assert.equal(vocabularyPreview.job?.errorCount, 0, 'valid vocabulary preview should have no errors');
const vocabularyImport = await request('/api/tenant-content/imports/vocabulary', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: vocabularyPreview.job.id },
});
assert.equal(vocabularyImport.item?.status, 'completed', 'valid vocabulary import should complete');
assert.ok(
(vocabularyImport.item?.insertedCount || 0) + (vocabularyImport.item?.updatedCount || 0) + (vocabularyImport.item?.skippedCount || 0) >= 2,
'vocabulary import should process unit and word idempotently',
);
const vocabularyUnits = await request('/api/catalog/vocabulary-units', {
query: { regionId: ids.region },
});
const importedVocabularyUnit = vocabularyUnits.items?.find(item => item.legacyId === 'integration-vocab-unit-001');
assert.ok(importedVocabularyUnit, 'catalog should expose imported vocabulary unit');
assert.equal(importedVocabularyUnit.entryId, vocabEntry.item.id, 'vocabulary unit should bind content entry');
const vocabularyWords = await request('/api/catalog/vocabulary-words', {
query: { unitId: importedVocabularyUnit.id },
});
assert.ok(vocabularyWords.items?.some(item => item.word === 'abandon' && item.contentNodeId), 'catalog should expose imported vocabulary word with node binding');
const handbookEntry = await request('/api/tenant-content/content-entries', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryKey: 'integration-handbook',
regionId: ids.region,
name: '集成测试知识手册入口',
entryType: 'handbook',
route: '/handbook',
order: 22,
},
});
const handbookRoot = await request('/api/tenant-content/content-nodes', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryId: handbookEntry.item.id,
regionId: ids.region,
nodeKey: 'integration-handbook-root',
name: '文化课',
nodeType: 'category',
isLeaf: false,
},
});
const handbookPreview = await request('/api/tenant-content/imports/preview/handbook', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceName: 'handbook-nested-import.json',
regionId: ids.region,
entryId: handbookEntry.item.id,
contentNodeId: handbookRoot.item.id,
subjects: [
{
legacyId: 'integration-handbook-chinese',
name: '大学语文',
type: 'guide',
icon: 'book-open',
color: '#10B981',
chapters: [
{
legacyId: 'integration-handbook-chapter-outline',
name: '一、语文考纲',
sections: [
{
legacyId: 'integration-handbook-section-outline',
name: '考纲解读',
entries: [
{
legacyId: 'integration-handbook-entry-outline',
title: '2024年天津专升本语文考试大纲',
summary: '全面解读语文考试要求和考点分布',
content: '一、考试性质\n\n天津市高职升本科招生统一考试是选拔性考试。',
tags: ['考纲', '必读'],
order: 1,
},
],
},
],
},
],
},
],
},
});
assert.equal(handbookPreview.job?.errorCount, 0, 'valid handbook preview should have no errors');
const handbookImport = await request('/api/tenant-content/imports/handbook', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: handbookPreview.job.id },
});
assert.equal(handbookImport.item?.status, 'completed', 'valid handbook import should complete');
assert.ok(
(handbookImport.item?.insertedCount || 0) + (handbookImport.item?.updatedCount || 0) + (handbookImport.item?.skippedCount || 0) >= 3,
'handbook import should process subject, chapter, and entry idempotently',
);
const handbookSubjects = await request('/api/catalog/handbook-subjects', {
query: { regionId: ids.region },
});
const importedHandbookSubject = handbookSubjects.items?.find(item => item.legacyId === 'integration-handbook-chinese');
assert.ok(importedHandbookSubject, 'catalog should expose imported handbook subject');
assert.equal(importedHandbookSubject.entryId, handbookEntry.item.id, 'handbook subject should bind content entry');
const handbookChapters = await request('/api/catalog/handbook-chapters', {
query: { subjectId: importedHandbookSubject.id },
});
const importedHandbookChapter = handbookChapters.items?.find(item => item.legacyId === 'integration-handbook-chapter-outline');
assert.ok(importedHandbookChapter, 'catalog should expose imported handbook chapter');
const handbookEntries = await request('/api/catalog/handbook-entries', {
query: { chapterId: importedHandbookChapter.id, includeContent: true },
});
assert.ok(
handbookEntries.items?.some(item => item.legacyId === 'integration-handbook-entry-outline' && item.content?.includes('选拔性考试')),
'catalog should expose imported handbook entry content',
);
const importJobs = await request('/api/tenant-content/imports', {
userId: TENANT_ADMIN_USER_ID,
query: { importType: 'vocabulary', limit: 10 },
});
assert.ok(importJobs.items?.some(item => item.id === vocabularyPreview.job.id && item.status === 'completed'), 'import job list should include completed vocabulary job');
const partnerImports = await request('/api/tenant-content/imports', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,