feat: import scorelines and videos

This commit is contained in:
Codex
2026-06-29 06:40:42 +08:00
parent fc289f7b42
commit 4954ad3ca9
16 changed files with 1397 additions and 64 deletions

View File

@@ -3012,6 +3012,195 @@ async function testTenantContentAssetsAndImports() {
'catalog should expose imported handbook entry content',
);
const invalidScorelinePreview = await request('/api/tenant-content/imports/preview/scoreline', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
regionId: ids.region,
items: [{ kind: 'record', year: 2026, score: 180 }],
},
});
assert.ok(
invalidScorelinePreview.issues?.some(issue => issue.code === 'SCORELINE_RECORD_SCHOOL_REQUIRED'),
'invalid scoreline preview should validate school reference',
);
const scorelinePreview = await request('/api/tenant-content/imports/preview/scoreline', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceName: 'scoreline-batch-import.json',
regionId: ids.region,
fields: [
{
legacyId: 'integration-scoreline-field-min',
fieldKey: 'minScore',
fieldName: '最低分',
fieldType: 'number',
unit: '分',
isFilter: true,
isTrend: true,
order: 1,
},
],
schools: [
{
legacyId: 'integration-scoreline-school-a',
name: '集成测试学院',
shortName: '集测学院',
type: 'public',
isHot: true,
},
],
majors: [
{
legacyId: 'integration-scoreline-major-a',
schoolLegacyId: 'integration-scoreline-school-a',
name: '软件工程',
hasRestriction: false,
},
],
records: [
{
legacyId: 'integration-scoreline-record-a-2025',
schoolLegacyId: 'integration-scoreline-school-a',
majorLegacyId: 'integration-scoreline-major-a',
schoolName: '集成测试学院',
majorName: '软件工程',
year: 2025,
fieldValues: { minScore: 188, planCount: 60 },
},
],
},
});
assert.equal(scorelinePreview.job?.errorCount, 0, 'valid scoreline preview should have no errors');
assert.equal(scorelinePreview.job?.validCount, 4, 'scoreline preview should normalize fields, schools, majors, and records');
const scorelineImport = await request('/api/tenant-content/imports/scoreline', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: scorelinePreview.job.id },
});
assert.equal(scorelineImport.item?.status, 'completed', 'scoreline import should complete');
assert.ok(
(scorelineImport.item?.insertedCount || 0) + (scorelineImport.item?.updatedCount || 0) + (scorelineImport.item?.skippedCount || 0) >= 4,
'scoreline import should process all valid rows',
);
const importedScorelineRecords = await request('/api/scoreline/records', {
query: { regionId: ids.region, year: 2025, pageSize: 50 },
});
assert.ok(
importedScorelineRecords.items?.some(item => item.schoolName === '集成测试学院' && item.fieldValues?.minScore === 188),
'public scoreline records should expose imported record',
);
const mixedScorelinePreview = await request('/api/tenant-content/imports/preview/scoreline', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceName: 'scoreline-mixed-items.json',
regionId: ids.region,
items: [
{
kind: 'record',
legacyId: 'integration-scoreline-mixed-record-2026',
schoolLegacyId: 'integration-scoreline-mixed-school',
majorLegacyId: 'integration-scoreline-mixed-major',
year: 2026,
minScore: 211,
},
{
kind: 'major',
legacyId: 'integration-scoreline-mixed-major',
schoolLegacyId: 'integration-scoreline-mixed-school',
name: '混排专业',
},
{
kind: 'school',
legacyId: 'integration-scoreline-mixed-school',
name: '混排测试学院',
},
],
},
});
assert.equal(mixedScorelinePreview.job?.errorCount, 0, 'mixed scoreline preview should accept out-of-order items');
const mixedScorelineImport = await request('/api/tenant-content/imports/scoreline', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: mixedScorelinePreview.job.id },
});
assert.equal(mixedScorelineImport.item?.status, 'completed', 'mixed scoreline import should order dependent rows');
const mixedScorelineRecords = await request('/api/scoreline/records', {
query: { regionId: ids.region, year: 2026, pageSize: 50 },
});
assert.ok(
mixedScorelineRecords.items?.some(item => item.schoolName === '混排测试学院' && item.fieldValues?.minScore === 211),
'mixed scoreline import should create record after school and major',
);
const invalidVideoPreview = await request('/api/tenant-content/imports/preview/videos', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
videos: [{ title: '缺少题目绑定', bindings: [{ videoType: 'specific' }] }],
},
});
assert.ok(
invalidVideoPreview.issues?.some(issue => issue.code === 'VIDEO_BINDING_QUESTION_REQUIRED'),
'invalid video preview should validate question binding',
);
const videoPreview = await request('/api/tenant-content/imports/preview/videos', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceName: 'video-batch-import.json',
videos: [
{
legacyId: 'integration-video-import-001',
title: '批量导入视频精讲',
description: '通过内容导入管线导入并绑定题目',
videoUrl: 'https://example.test/videos/imported.mp4',
thumbnailUrl: 'https://example.test/videos/imported.jpg',
durationSeconds: 120,
knowledgeTags: ['批量导入', '视频精讲'],
isGeneral: false,
subjectId: ids.subject,
accessMode: 'free',
bindings: [
{
legacyId: 'integration-video-binding-001',
questionId: ids.question,
videoType: 'specific',
order: 1,
},
],
},
],
},
});
assert.equal(videoPreview.job?.errorCount, 0, 'valid video preview should have no errors');
const videoImport = await request('/api/tenant-content/imports/videos', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: videoPreview.job.id },
});
assert.equal(videoImport.item?.status, 'completed', 'video import should complete');
assert.ok(
(videoImport.item?.insertedCount || 0) + (videoImport.item?.updatedCount || 0) + (videoImport.item?.skippedCount || 0) >= 2,
'video import should process video and question binding',
);
const questionVideosAfterImport = await request(`/api/questions/${ids.question}/videos`);
assert.ok(
questionVideosAfterImport.videos?.some(item => item.legacyId === 'integration-video-import-001' && item.title === '批量导入视频精讲'),
'question video API should expose imported video binding',
);
const importJobs = await request('/api/tenant-content/imports', {
userId: TENANT_ADMIN_USER_ID,
query: { importType: 'vocabulary', limit: 10 },

View File

@@ -41,6 +41,11 @@ async function runWorkerOnce() {
return output;
}
function countFromWorkerOutput(output, name) {
const match = output.match(new RegExp(`${name}=(\\d+)`));
return match ? Number(match[1]) : 0;
}
async function cleanup(pool) {
await pool.query(
`
@@ -109,8 +114,8 @@ async function main() {
seeded = true;
const output = await runWorkerOnce();
assert.match(output, /verified=1/, 'worker should verify one asset');
assert.match(output, /failed=1/, 'worker should fail one mismatched asset');
assert.ok(countFromWorkerOutput(output, 'verified') >= 1, 'worker should verify at least one asset');
assert.ok(countFromWorkerOutput(output, 'failed') >= 1, 'worker should fail at least one mismatched asset');
const assets = await pool.query(
`