forked from wangziqi/gongxue-base
feat: add import post-check templates
This commit is contained in:
467
apps/api/src/features/tenant-content/import-postcheck.ts
Normal file
467
apps/api/src/features/tenant-content/import-postcheck.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
import type pg from 'pg';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { queryOne, transaction } from '../../core/db.js';
|
||||
import { readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
|
||||
|
||||
type ImportType = 'questions' | 'vocabulary' | 'handbook' | 'scoreline' | 'videos';
|
||||
|
||||
interface ImportJobForPostCheck {
|
||||
id: string;
|
||||
importType: ImportType;
|
||||
status: string;
|
||||
totalCount: number;
|
||||
validCount: number;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
targetCollectionId: string | null;
|
||||
summary: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PostCheckIssue {
|
||||
severity: 'error' | 'warning';
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PostCheckSummary {
|
||||
status: 'passed' | 'warning' | 'failed';
|
||||
checkedAt: string;
|
||||
jobId: string;
|
||||
importType: ImportType;
|
||||
counts: Record<string, number>;
|
||||
issues: PostCheckIssue[];
|
||||
}
|
||||
|
||||
async function loadCompletedJob(client: pg.PoolClient, auth: TenantContentAuth, jobId: string) {
|
||||
const job = await client.query<ImportJobForPostCheck>(
|
||||
`
|
||||
select id, import_type as "importType", status,
|
||||
total_count as "totalCount", valid_count as "validCount",
|
||||
error_count as "errorCount", warning_count as "warningCount",
|
||||
target_collection_id as "targetCollectionId",
|
||||
summary
|
||||
from public.content_import_jobs
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
`,
|
||||
[auth.tenantId, jobId],
|
||||
);
|
||||
|
||||
const item = job.rows[0];
|
||||
if (!item) throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
|
||||
if (!['completed', 'completed_with_errors'].includes(item.status)) {
|
||||
throw new HttpError(409, 'Import job is not completed yet', 'IMPORT_JOB_NOT_COMPLETED');
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
async function baseCounts(client: pg.PoolClient, tenantId: string, jobId: string) {
|
||||
const result = await client.query<{
|
||||
item_count: string;
|
||||
valid_item_count: string;
|
||||
processed_item_count: string;
|
||||
target_missing_count: string;
|
||||
target_id_count: string;
|
||||
inserted_count: string;
|
||||
updated_count: string;
|
||||
skipped_count: string;
|
||||
failed_count: string;
|
||||
}>(
|
||||
`
|
||||
select count(*)::text as item_count,
|
||||
count(*) filter (where status in ('valid', 'inserted', 'updated', 'skipped'))::text as valid_item_count,
|
||||
count(*) filter (where status in ('inserted', 'updated', 'skipped'))::text as processed_item_count,
|
||||
count(*) filter (where status in ('inserted', 'updated', 'skipped') and target_id is null)::text as target_missing_count,
|
||||
count(target_id)::text as target_id_count,
|
||||
count(*) filter (where status = 'inserted')::text as inserted_count,
|
||||
count(*) filter (where status = 'updated')::text as updated_count,
|
||||
count(*) filter (where status = 'skipped')::text as skipped_count,
|
||||
count(*) filter (where status = 'failed')::text as failed_count
|
||||
from public.content_import_items
|
||||
where tenant_id = $1 and job_id = $2
|
||||
`,
|
||||
[tenantId, jobId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
itemCount: Number(row?.item_count || 0),
|
||||
validItemCount: Number(row?.valid_item_count || 0),
|
||||
processedItemCount: Number(row?.processed_item_count || 0),
|
||||
targetMissingCount: Number(row?.target_missing_count || 0),
|
||||
targetIdCount: Number(row?.target_id_count || 0),
|
||||
insertedCount: Number(row?.inserted_count || 0),
|
||||
updatedCount: Number(row?.updated_count || 0),
|
||||
skippedCount: Number(row?.skipped_count || 0),
|
||||
failedCount: Number(row?.failed_count || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function addBaseIssues(job: ImportJobForPostCheck, counts: Record<string, number>, issues: PostCheckIssue[]) {
|
||||
if (counts.itemCount !== job.totalCount) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
code: 'IMPORT_ITEM_COUNT_MISMATCH',
|
||||
message: 'Import item count does not match job totalCount',
|
||||
details: { itemCount: counts.itemCount, totalCount: job.totalCount },
|
||||
});
|
||||
}
|
||||
if (counts.processedItemCount !== job.validCount) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'IMPORT_PROCESSED_COUNT_MISMATCH',
|
||||
message: 'Processed import item count does not match validCount',
|
||||
details: { processedItemCount: counts.processedItemCount, validCount: job.validCount },
|
||||
});
|
||||
}
|
||||
if (counts.targetMissingCount > 0) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'IMPORT_TARGET_ID_MISSING',
|
||||
message: 'Some processed import items do not have a target_id',
|
||||
details: { targetMissingCount: counts.targetMissingCount },
|
||||
});
|
||||
}
|
||||
if (counts.failedCount > 0) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'IMPORT_ITEM_FAILED',
|
||||
message: 'Some import items are marked failed',
|
||||
details: { failedCount: counts.failedCount },
|
||||
});
|
||||
}
|
||||
if (job.errorCount > 0 && job.status === 'completed_with_errors') {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
code: 'IMPORT_COMPLETED_WITH_VALIDATION_ERRORS',
|
||||
message: 'Import completed with validation errors because allowPartial was used',
|
||||
details: { errorCount: job.errorCount },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function countExistingTargets(client: pg.PoolClient, tenantId: string, jobId: string, tableName: string) {
|
||||
const result = await client.query<{ existing_count: string }>(
|
||||
`
|
||||
select count(distinct item.target_id)::text as existing_count
|
||||
from public.content_import_items item
|
||||
join ${tableName} target on target.tenant_id = item.tenant_id and target.id = item.target_id
|
||||
where item.tenant_id = $1
|
||||
and item.job_id = $2
|
||||
and item.status in ('inserted', 'updated', 'skipped')
|
||||
and item.target_id is not null
|
||||
`,
|
||||
[tenantId, jobId],
|
||||
);
|
||||
return Number(result.rows[0]?.existing_count || 0);
|
||||
}
|
||||
|
||||
async function questionChecks(client: pg.PoolClient, auth: TenantContentAuth, job: ImportJobForPostCheck, counts: Record<string, number>) {
|
||||
const issues: PostCheckIssue[] = [];
|
||||
const existingTargetCount = await countExistingTargets(client, auth.tenantId, job.id, 'public.questions');
|
||||
counts.existingTargetCount = existingTargetCount;
|
||||
|
||||
const versions = await client.query<{ count: string }>(
|
||||
`
|
||||
select count(distinct q.id)::text as count
|
||||
from public.content_import_items item
|
||||
join public.questions q on q.tenant_id = item.tenant_id and q.id = item.target_id
|
||||
join public.question_versions v on v.tenant_id = q.tenant_id and v.id = q.current_version_id and v.question_id = q.id
|
||||
where item.tenant_id = $1
|
||||
and item.job_id = $2
|
||||
and item.status in ('inserted', 'updated', 'skipped')
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
counts.questionWithCurrentVersionCount = Number(versions.rows[0]?.count || 0);
|
||||
|
||||
if (counts.existingTargetCount !== counts.processedItemCount) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'QUESTION_TARGET_MISSING',
|
||||
message: 'Some imported question targets are missing',
|
||||
details: { existingTargetCount: counts.existingTargetCount, processedItemCount: counts.processedItemCount },
|
||||
});
|
||||
}
|
||||
if (counts.questionWithCurrentVersionCount !== counts.processedItemCount) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'QUESTION_VERSION_MISSING',
|
||||
message: 'Some imported questions do not have a current version',
|
||||
details: { questionWithCurrentVersionCount: counts.questionWithCurrentVersionCount, processedItemCount: counts.processedItemCount },
|
||||
});
|
||||
}
|
||||
|
||||
if (job.targetCollectionId) {
|
||||
const collectionItems = await client.query<{ count: string }>(
|
||||
`
|
||||
select count(distinct item.target_id)::text as count
|
||||
from public.content_import_items item
|
||||
join public.question_collection_items qci
|
||||
on qci.tenant_id = item.tenant_id
|
||||
and qci.question_id = item.target_id
|
||||
and qci.collection_id = $3
|
||||
where item.tenant_id = $1
|
||||
and item.job_id = $2
|
||||
and item.status in ('inserted', 'updated', 'skipped')
|
||||
`,
|
||||
[auth.tenantId, job.id, job.targetCollectionId],
|
||||
);
|
||||
counts.collectionBindingCount = Number(collectionItems.rows[0]?.count || 0);
|
||||
if (counts.collectionBindingCount !== counts.processedItemCount) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'QUESTION_COLLECTION_BINDING_MISSING',
|
||||
message: 'Some imported questions are not bound to the target collection',
|
||||
details: { collectionBindingCount: counts.collectionBindingCount, processedItemCount: counts.processedItemCount, collectionId: job.targetCollectionId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function vocabularyChecks(client: pg.PoolClient, auth: TenantContentAuth, job: ImportJobForPostCheck, counts: Record<string, number>) {
|
||||
const issues: PostCheckIssue[] = [];
|
||||
counts.existingTargetCount = await countExistingTargets(client, auth.tenantId, job.id, 'public.vocabulary_units');
|
||||
const words = await client.query<{ count: string }>(
|
||||
`
|
||||
select count(w.id)::text as count
|
||||
from public.content_import_items item
|
||||
join public.vocabulary_units unit on unit.tenant_id = item.tenant_id and unit.id = item.target_id
|
||||
left join public.vocabulary_words w on w.tenant_id = unit.tenant_id and w.unit_id = unit.id
|
||||
where item.tenant_id = $1
|
||||
and item.job_id = $2
|
||||
and item.status in ('inserted', 'updated', 'skipped')
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
counts.vocabularyWordCount = Number(words.rows[0]?.count || 0);
|
||||
if (counts.existingTargetCount !== counts.processedItemCount) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'VOCABULARY_UNIT_TARGET_MISSING',
|
||||
message: 'Some imported vocabulary units are missing',
|
||||
details: { existingTargetCount: counts.existingTargetCount, processedItemCount: counts.processedItemCount },
|
||||
});
|
||||
}
|
||||
if (counts.processedItemCount > 0 && counts.vocabularyWordCount <= 0) {
|
||||
issues.push({ severity: 'error', code: 'VOCABULARY_WORDS_MISSING', message: 'Imported vocabulary units have no words' });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function handbookChecks(client: pg.PoolClient, auth: TenantContentAuth, job: ImportJobForPostCheck, counts: Record<string, number>) {
|
||||
const issues: PostCheckIssue[] = [];
|
||||
counts.existingTargetCount = await countExistingTargets(client, auth.tenantId, job.id, 'public.handbook_subjects');
|
||||
const children = await client.query<{ chapter_count: string; entry_count: string }>(
|
||||
`
|
||||
select count(distinct c.id)::text as chapter_count,
|
||||
count(distinct e.id)::text as entry_count
|
||||
from public.content_import_items item
|
||||
join public.handbook_subjects subject on subject.tenant_id = item.tenant_id and subject.id = item.target_id
|
||||
left join public.handbook_chapters c on c.tenant_id = subject.tenant_id and c.subject_id = subject.id
|
||||
left join public.handbook_entries e on e.tenant_id = c.tenant_id and e.chapter_id = c.id
|
||||
where item.tenant_id = $1
|
||||
and item.job_id = $2
|
||||
and item.status in ('inserted', 'updated', 'skipped')
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
counts.handbookChapterCount = Number(children.rows[0]?.chapter_count || 0);
|
||||
counts.handbookEntryCount = Number(children.rows[0]?.entry_count || 0);
|
||||
if (counts.existingTargetCount !== counts.processedItemCount) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'HANDBOOK_SUBJECT_TARGET_MISSING',
|
||||
message: 'Some imported handbook subjects are missing',
|
||||
details: { existingTargetCount: counts.existingTargetCount, processedItemCount: counts.processedItemCount },
|
||||
});
|
||||
}
|
||||
if (counts.processedItemCount > 0 && counts.handbookEntryCount <= 0) {
|
||||
issues.push({ severity: 'warning', code: 'HANDBOOK_ENTRIES_EMPTY', message: 'Imported handbook subjects do not contain entries' });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function scorelineChecks(client: pg.PoolClient, auth: TenantContentAuth, job: ImportJobForPostCheck, counts: Record<string, number>) {
|
||||
const kinds = await client.query<{ kind: string; processed_count: string; existing_count: string }>(
|
||||
`
|
||||
with processed as (
|
||||
select target_id, normalized_payload ->> 'kind' as kind
|
||||
from public.content_import_items
|
||||
where tenant_id = $1
|
||||
and job_id = $2
|
||||
and status in ('inserted', 'updated', 'skipped')
|
||||
)
|
||||
select 'field' as kind,
|
||||
count(*) filter (where p.kind = 'field')::text as processed_count,
|
||||
count(f.id)::text as existing_count
|
||||
from processed p
|
||||
left join public.scoreline_fields f on p.kind = 'field' and f.tenant_id = $1 and f.id = p.target_id
|
||||
union all
|
||||
select 'school',
|
||||
count(*) filter (where p.kind = 'school')::text,
|
||||
count(s.id)::text
|
||||
from processed p
|
||||
left join public.scoreline_schools s on p.kind = 'school' and s.tenant_id = $1 and s.id = p.target_id
|
||||
union all
|
||||
select 'major',
|
||||
count(*) filter (where p.kind = 'major')::text,
|
||||
count(m.id)::text
|
||||
from processed p
|
||||
left join public.scoreline_majors m on p.kind = 'major' and m.tenant_id = $1 and m.id = p.target_id
|
||||
union all
|
||||
select 'record',
|
||||
count(*) filter (where p.kind = 'record')::text,
|
||||
count(r.id)::text
|
||||
from processed p
|
||||
left join public.scoreline_records r on p.kind = 'record' and r.tenant_id = $1 and r.id = p.target_id
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
|
||||
const issues: PostCheckIssue[] = [];
|
||||
for (const row of kinds.rows) {
|
||||
const processed = Number(row.processed_count || 0);
|
||||
const existing = Number(row.existing_count || 0);
|
||||
counts[`scoreline${row.kind[0].toUpperCase()}${row.kind.slice(1)}ProcessedCount`] = processed;
|
||||
counts[`scoreline${row.kind[0].toUpperCase()}${row.kind.slice(1)}ExistingCount`] = existing;
|
||||
if (processed !== existing) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'SCORELINE_TARGET_MISSING',
|
||||
message: `Some imported scoreline ${row.kind} targets are missing`,
|
||||
details: { kind: row.kind, processed, existing },
|
||||
});
|
||||
}
|
||||
}
|
||||
counts.existingTargetCount = kinds.rows.reduce((sum, row) => sum + Number(row.existing_count || 0), 0);
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function videoChecks(client: pg.PoolClient, auth: TenantContentAuth, job: ImportJobForPostCheck, counts: Record<string, number>) {
|
||||
const issues: PostCheckIssue[] = [];
|
||||
counts.existingTargetCount = await countExistingTargets(client, auth.tenantId, job.id, 'public.video_explanations');
|
||||
const bindings = await client.query<{ count: string }>(
|
||||
`
|
||||
select count(qv.id)::text as count
|
||||
from public.content_import_items item
|
||||
join public.video_explanations video on video.tenant_id = item.tenant_id and video.id = item.target_id
|
||||
left join public.question_videos qv on qv.tenant_id = video.tenant_id and qv.video_id = video.id
|
||||
where item.tenant_id = $1
|
||||
and item.job_id = $2
|
||||
and item.status in ('inserted', 'updated', 'skipped')
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
counts.videoBindingCount = Number(bindings.rows[0]?.count || 0);
|
||||
if (counts.existingTargetCount !== counts.processedItemCount) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'VIDEO_TARGET_MISSING',
|
||||
message: 'Some imported videos are missing',
|
||||
details: { existingTargetCount: counts.existingTargetCount, processedItemCount: counts.processedItemCount },
|
||||
});
|
||||
}
|
||||
if (counts.processedItemCount > 0 && counts.videoBindingCount <= 0) {
|
||||
issues.push({ severity: 'warning', code: 'VIDEO_BINDINGS_EMPTY', message: 'Imported videos do not have question bindings' });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function domainChecks(client: pg.PoolClient, auth: TenantContentAuth, job: ImportJobForPostCheck, counts: Record<string, number>) {
|
||||
switch (job.importType) {
|
||||
case 'questions':
|
||||
return questionChecks(client, auth, job, counts);
|
||||
case 'vocabulary':
|
||||
return vocabularyChecks(client, auth, job, counts);
|
||||
case 'handbook':
|
||||
return handbookChecks(client, auth, job, counts);
|
||||
case 'scoreline':
|
||||
return scorelineChecks(client, auth, job, counts);
|
||||
case 'videos':
|
||||
return videoChecks(client, auth, job, counts);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function finalStatus(issues: PostCheckIssue[]): PostCheckSummary['status'] {
|
||||
if (issues.some(issue => issue.severity === 'error')) return 'failed';
|
||||
if (issues.some(issue => issue.severity === 'warning')) return 'warning';
|
||||
return 'passed';
|
||||
}
|
||||
|
||||
async function runPostCheck(auth: TenantContentAuth, jobId: string) {
|
||||
return transaction(async client => {
|
||||
const job = await loadCompletedJob(client, auth, jobId);
|
||||
const counts = await baseCounts(client, auth.tenantId, job.id);
|
||||
const issues: PostCheckIssue[] = [];
|
||||
addBaseIssues(job, counts, issues);
|
||||
issues.push(...await domainChecks(client, auth, job, counts));
|
||||
|
||||
const summary: PostCheckSummary = {
|
||||
status: finalStatus(issues),
|
||||
checkedAt: new Date().toISOString(),
|
||||
jobId: job.id,
|
||||
importType: job.importType,
|
||||
counts,
|
||||
issues,
|
||||
};
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set summary = coalesce(summary, '{}'::jsonb) || $3::jsonb,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, job.id, JSON.stringify({ importPostCheck: summary })],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, $3, 'content_import_job', $4, $5::jsonb)
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
auth.userId,
|
||||
`content.import.${job.importType}.post_checked`,
|
||||
job.id,
|
||||
JSON.stringify({ status: summary.status, issueCount: summary.issues.length, counts: summary.counts }),
|
||||
],
|
||||
);
|
||||
|
||||
return summary;
|
||||
});
|
||||
}
|
||||
|
||||
export async function importPostCheckRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const jobId = requiredString(body, 'jobId');
|
||||
const item = await runPostCheck(auth, jobId);
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function importPostCheckStatusRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const jobId = stringParam(ctx, 'jobId');
|
||||
if (!jobId) throw new HttpError(400, 'jobId is required', 'REQUIRED_FIELD');
|
||||
|
||||
const job = await queryOne<{ id: string; summary: Record<string, unknown> }>(
|
||||
'select id, summary from public.content_import_jobs where tenant_id = $1 and id = $2 limit 1',
|
||||
[auth.tenantId, jobId],
|
||||
);
|
||||
if (!job) throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
|
||||
|
||||
return {
|
||||
item: {
|
||||
jobId: job.id,
|
||||
importPostCheck: job.summary?.importPostCheck || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
284
apps/api/src/features/tenant-content/import-templates.ts
Normal file
284
apps/api/src/features/tenant-content/import-templates.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { stringParam } from '../../core/request.js';
|
||||
import { requireTenantContentEditor } from './auth.js';
|
||||
|
||||
type ImportType = 'questions' | 'vocabulary' | 'handbook' | 'scoreline' | 'videos';
|
||||
type TemplateFormat = 'json' | 'csv';
|
||||
|
||||
interface FieldSpec {
|
||||
field: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
aliases: string[];
|
||||
description: string;
|
||||
example: unknown;
|
||||
}
|
||||
|
||||
interface ImportTemplateSpec {
|
||||
importType: ImportType;
|
||||
title: string;
|
||||
description: string;
|
||||
fields: FieldSpec[];
|
||||
csvRows: string[][];
|
||||
jsonExample: unknown;
|
||||
}
|
||||
|
||||
const IMPORT_TYPES = new Set<ImportType>(['questions', 'vocabulary', 'handbook', 'scoreline', 'videos']);
|
||||
const FORMATS = new Set<TemplateFormat>(['json', 'csv']);
|
||||
|
||||
const questionFields: FieldSpec[] = [
|
||||
{ field: 'legacyId', label: '旧系统ID/外部ID', required: false, aliases: ['legacy_id', 'externalId', 'external_id', 'id'], description: '用于幂等更新,同一租户内重复导入会更新或跳过。', example: 'tj-english-2026-001' },
|
||||
{ field: 'type', label: '题型', required: true, aliases: ['题型', 'questionType'], description: '支持 choice、multi、judge、reading、short_answer 等新题型编码。', example: 'choice' },
|
||||
{ field: 'typeLabel', label: '题型显示名', required: false, aliases: ['type_label', '题型名称'], description: '用于保留旧卷面上的题型名称。', example: '单选题' },
|
||||
{ field: 'content', label: '题干', required: true, aliases: ['题干', 'stem', 'question'], description: '支持 Markdown、KaTeX、图片 URL 和表格。', example: '多租户 SaaS 最重要的安全边界是什么?' },
|
||||
{ field: 'options', label: '选项', required: false, aliases: ['选项', 'choices', '选项A/选项B'], description: '客观题必填;CSV 可使用选项A、选项B 等列。', example: ['前端隐藏', '后端权限和 RLS'] },
|
||||
{ field: 'correctOptionIndices', label: '正确选项索引', required: false, aliases: ['correct_option_indices', '答案', 'answer'], description: '客观题答案,从 0 开始;CSV 也支持 A/B/C/D。', example: [1] },
|
||||
{ field: 'answerText', label: '文字答案', required: false, aliases: ['answer_text', '主观题答案'], description: '填空、简答、论述等主观题使用。', example: '以后端权限和数据库 RLS 为准。' },
|
||||
{ field: 'explanation', label: '解析', required: false, aliases: ['解析', 'analysis'], description: '题目解析内容。', example: '前端只做展示,最终权限必须由后端强制。' },
|
||||
{ field: 'difficulty', label: '难度', required: false, aliases: ['难度'], description: '建议 1-5。', example: 2 },
|
||||
{ field: 'tags', label: '标签', required: false, aliases: ['标签', 'tag'], description: 'JSON 数组或 CSV 中用 | 分隔。', example: ['安全', '多租户'] },
|
||||
];
|
||||
|
||||
const vocabularyFields: FieldSpec[] = [
|
||||
{ field: 'unitLegacyId', label: '单元外部ID', required: false, aliases: ['unit_legacy_id', 'unitId'], description: '单词单元幂等键。', example: 'tj-english-core-001' },
|
||||
{ field: 'unitName', label: '单元名称', required: true, aliases: ['unit', '单元', 'name'], description: '单词所属单元名称。', example: '核心词汇 Unit 1' },
|
||||
{ field: 'wordLegacyId', label: '单词外部ID', required: false, aliases: ['word_legacy_id', 'legacyId'], description: '单词幂等键。', example: 'word-scale' },
|
||||
{ field: 'word', label: '单词', required: true, aliases: ['单词'], description: '英文单词或词组。', example: 'scale' },
|
||||
{ field: 'phonetic', label: '音标', required: false, aliases: ['音标'], description: '音标展示文本。', example: '/skeil/' },
|
||||
{ field: 'meaning', label: '释义', required: true, aliases: ['释义', '中文'], description: '中文释义。', example: 'n. 规模;等级' },
|
||||
{ field: 'example', label: '例句', required: false, aliases: ['例句'], description: '英文例句。', example: 'The platform must scale safely.' },
|
||||
{ field: 'difficulty', label: '难度', required: false, aliases: ['难度'], description: '建议 1-5。', example: 2 },
|
||||
{ field: 'tags', label: '标签', required: false, aliases: ['标签'], description: 'JSON 数组或 CSV 中用 | 分隔。', example: ['高频', 'SaaS'] },
|
||||
];
|
||||
|
||||
const handbookFields: FieldSpec[] = [
|
||||
{ field: 'subjectName', label: '手册科目', required: true, aliases: ['subject', '手册', 'bookName'], description: '知识手册顶层名称。', example: '专升本英语知识手册' },
|
||||
{ field: 'chapterName', label: '章节', required: true, aliases: ['chapter', '章节'], description: '章节名称。', example: '第一章 语法基础' },
|
||||
{ field: 'sectionName', label: '小节', required: false, aliases: ['section', '小节'], description: '可选小节名称。', example: '名词性从句' },
|
||||
{ field: 'title', label: '知识点标题', required: true, aliases: ['entryTitle', '标题'], description: '知识点条目标题。', example: 'that 引导的主语从句' },
|
||||
{ field: 'content', label: '正文', required: true, aliases: ['正文', 'markdown'], description: 'Markdown 正文,允许图片和公式。', example: '主语从句可放在句首,也可用 it 作形式主语。' },
|
||||
{ field: 'summary', label: '摘要', required: false, aliases: ['摘要'], description: '条目摘要。', example: '主语从句核心用法。' },
|
||||
{ field: 'tags', label: '标签', required: false, aliases: ['标签'], description: 'JSON 数组或 CSV 中用 | 分隔。', example: ['语法'] },
|
||||
];
|
||||
|
||||
const scorelineFields: FieldSpec[] = [
|
||||
{ field: 'kind', label: '数据类型', required: true, aliases: ['type', '类型'], description: 'field、school、major、record。', example: 'record' },
|
||||
{ field: 'legacyId', label: '外部ID', required: false, aliases: ['legacy_id', 'externalId'], description: '幂等键。', example: 'tj-score-2026-school-major' },
|
||||
{ field: 'regionId', label: '地区ID', required: false, aliases: ['region_id'], description: '可由导入目标地区补齐。', example: '00000000-0000-0000-0000-000000000301' },
|
||||
{ field: 'schoolName', label: '院校名称', required: false, aliases: ['school', '院校'], description: 'school、major、record 常用。', example: '天津职业技术师范大学' },
|
||||
{ field: 'majorName', label: '专业名称', required: false, aliases: ['major', '专业'], description: 'major、record 常用。', example: '软件工程' },
|
||||
{ field: 'year', label: '年份', required: false, aliases: ['年份'], description: 'record 必填。', example: 2026 },
|
||||
{ field: 'fieldKey', label: '动态字段键', required: false, aliases: ['field_key'], description: 'field 必填,例如 minScore。', example: 'minScore' },
|
||||
{ field: 'fieldName', label: '动态字段名', required: false, aliases: ['field_name'], description: 'field 展示名称。', example: '最低分' },
|
||||
{ field: 'fieldValues', label: '分数线字段值', required: false, aliases: ['values', '分数字段'], description: 'record 的动态字段 JSON;CSV 支持额外列。', example: { minScore: 188 } },
|
||||
];
|
||||
|
||||
const videoFields: FieldSpec[] = [
|
||||
{ field: 'legacyId', label: '视频外部ID', required: false, aliases: ['legacy_id', 'externalId'], description: '视频幂等键。', example: 'video-question-001' },
|
||||
{ field: 'title', label: '标题', required: true, aliases: ['视频标题', 'name'], description: '视频标题。', example: '多租户隔离题解析' },
|
||||
{ field: 'videoUrl', label: '视频 URL', required: false, aliases: ['video_url', 'url'], description: '外部视频 URL;商用建议改用 content_assets。', example: 'https://cdn.example.test/video.mp4' },
|
||||
{ field: 'assetId', label: '资源ID', required: false, aliases: ['asset_id'], description: '对象存储资源台账 ID。', example: '00000000-0000-0000-0000-000000000000' },
|
||||
{ field: 'accessMode', label: '访问模式', required: false, aliases: ['access_mode'], description: 'free、svip、video_quota。', example: 'video_quota' },
|
||||
{ field: 'questionId', label: '题目ID', required: false, aliases: ['question_id'], description: '绑定题目 ID。', example: '00000000-0000-0000-0000-000000000401' },
|
||||
{ field: 'legacyQuestionId', label: '题目外部ID', required: false, aliases: ['legacy_question_id'], description: '按旧题目 ID 绑定。', example: 'tj-english-2026-001' },
|
||||
{ field: 'videoType', label: '视频类型', required: false, aliases: ['video_type'], description: 'specific、general 等。', example: 'specific' },
|
||||
];
|
||||
|
||||
const SPECS: Record<ImportType, ImportTemplateSpec> = {
|
||||
questions: {
|
||||
importType: 'questions',
|
||||
title: '题目导入模板',
|
||||
description: '用于导入刷题题库,后端会校验题型、答案、目标科目/分类/集合和租户隔离。',
|
||||
fields: questionFields,
|
||||
csvRows: [
|
||||
['legacyId', '题型', '题干', '选项A', '选项B', '选项C', '选项D', '答案', '解析', '难度', '标签'],
|
||||
['tj-english-2026-001', 'choice', '多租户 SaaS 最重要的安全边界是什么?', '前端隐藏菜单', '后端权限和 RLS', '浏览器缓存', '静态页面', 'B', '最终权限以后端和 RLS 为准。', '2', '安全|多租户'],
|
||||
],
|
||||
jsonExample: {
|
||||
items: [
|
||||
{
|
||||
legacyId: 'tj-english-2026-001',
|
||||
type: 'choice',
|
||||
typeLabel: '单选题',
|
||||
content: '多租户 SaaS 最重要的安全边界是什么?',
|
||||
options: ['前端隐藏菜单', '后端权限和 RLS', '浏览器缓存', '静态页面'],
|
||||
correctOptionIndices: [1],
|
||||
explanation: '最终权限以后端和 RLS 为准。',
|
||||
difficulty: 2,
|
||||
tags: ['安全', '多租户'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
vocabulary: {
|
||||
importType: 'vocabulary',
|
||||
title: '单词导入模板',
|
||||
description: '用于导入背单词单元和单词,后端会按单元归组并幂等写入。',
|
||||
fields: vocabularyFields,
|
||||
csvRows: [
|
||||
['unitLegacyId', 'unitName', 'wordLegacyId', 'word', 'phonetic', 'meaning', 'example', 'difficulty', 'tags'],
|
||||
['tj-english-core-001', '核心词汇 Unit 1', 'word-scale', 'scale', '/skeil/', 'n. 规模;等级', 'The platform must scale safely.', '2', '高频|SaaS'],
|
||||
],
|
||||
jsonExample: {
|
||||
units: [
|
||||
{
|
||||
legacyId: 'tj-english-core-001',
|
||||
name: '核心词汇 Unit 1',
|
||||
words: [
|
||||
{
|
||||
legacyId: 'word-scale',
|
||||
word: 'scale',
|
||||
phonetic: '/skeil/',
|
||||
meaning: 'n. 规模;等级',
|
||||
example: 'The platform must scale safely.',
|
||||
difficulty: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
handbook: {
|
||||
importType: 'handbook',
|
||||
title: '知识手册导入模板',
|
||||
description: '用于导入知识手册科目、章节、小节和知识点。',
|
||||
fields: handbookFields,
|
||||
csvRows: [
|
||||
['subjectName', 'chapterName', 'sectionName', 'title', 'summary', 'content', 'tags'],
|
||||
['专升本英语知识手册', '第一章 语法基础', '名词性从句', 'that 引导的主语从句', '主语从句核心用法。', '主语从句可放在句首,也可用 it 作形式主语。', '语法'],
|
||||
],
|
||||
jsonExample: {
|
||||
subjects: [
|
||||
{
|
||||
name: '专升本英语知识手册',
|
||||
chapters: [
|
||||
{
|
||||
name: '第一章 语法基础',
|
||||
sections: [
|
||||
{
|
||||
name: '名词性从句',
|
||||
entries: [
|
||||
{
|
||||
title: 'that 引导的主语从句',
|
||||
summary: '主语从句核心用法。',
|
||||
content: '主语从句可放在句首,也可用 it 作形式主语。',
|
||||
tags: ['语法'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
scoreline: {
|
||||
importType: 'scoreline',
|
||||
title: '分数线导入模板',
|
||||
description: '用于导入动态字段、院校、专业和年份分数线记录。',
|
||||
fields: scorelineFields,
|
||||
csvRows: [
|
||||
['kind', 'legacyId', 'schoolName', 'majorName', 'year', 'fieldKey', 'fieldName', 'minScore'],
|
||||
['field', 'score-field-min', '', '', '', 'minScore', '最低分', ''],
|
||||
['school', 'score-school-001', '天津职业技术师范大学', '', '', '', '', ''],
|
||||
['major', 'score-major-001', '天津职业技术师范大学', '软件工程', '', '', '', ''],
|
||||
['record', 'score-record-2026', '天津职业技术师范大学', '软件工程', '2026', '', '', '188'],
|
||||
],
|
||||
jsonExample: {
|
||||
fields: [{ legacyId: 'score-field-min', fieldKey: 'minScore', fieldName: '最低分', fieldType: 'number', isTrend: true }],
|
||||
schools: [{ legacyId: 'score-school-001', name: '天津职业技术师范大学' }],
|
||||
majors: [{ legacyId: 'score-major-001', schoolLegacyId: 'score-school-001', name: '软件工程' }],
|
||||
records: [{ legacyId: 'score-record-2026', schoolLegacyId: 'score-school-001', majorLegacyId: 'score-major-001', year: 2026, fieldValues: { minScore: 188 } }],
|
||||
},
|
||||
},
|
||||
videos: {
|
||||
importType: 'videos',
|
||||
title: '视频解析导入模板',
|
||||
description: '用于导入视频解析元数据并绑定到题目。',
|
||||
fields: videoFields,
|
||||
csvRows: [
|
||||
['legacyId', 'title', 'videoUrl', 'accessMode', 'legacyQuestionId', 'videoType'],
|
||||
['video-question-001', '多租户隔离题解析', 'https://cdn.example.test/video.mp4', 'video_quota', 'tj-english-2026-001', 'specific'],
|
||||
],
|
||||
jsonExample: {
|
||||
videos: [
|
||||
{
|
||||
legacyId: 'video-question-001',
|
||||
title: '多租户隔离题解析',
|
||||
videoUrl: 'https://cdn.example.test/video.mp4',
|
||||
accessMode: 'video_quota',
|
||||
bindings: [{ legacyQuestionId: 'tj-english-2026-001', videoType: 'specific' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function importTypeFromQuery(value: string): ImportType {
|
||||
if (!IMPORT_TYPES.has(value as ImportType)) {
|
||||
throw new HttpError(400, 'Unsupported importType', 'INVALID_IMPORT_TYPE');
|
||||
}
|
||||
return value as ImportType;
|
||||
}
|
||||
|
||||
function formatFromQuery(value: string): TemplateFormat {
|
||||
const format = value || 'json';
|
||||
if (!FORMATS.has(format as TemplateFormat)) {
|
||||
throw new HttpError(400, 'Unsupported template format', 'INVALID_TEMPLATE_FORMAT');
|
||||
}
|
||||
return format as TemplateFormat;
|
||||
}
|
||||
|
||||
function csvEscape(value: unknown) {
|
||||
const text = String(value ?? '');
|
||||
if (!/[",\r\n]/.test(text)) return text;
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function toCsv(rows: string[][]) {
|
||||
return `${rows.map(row => row.map(csvEscape).join(',')).join('\n')}\n`;
|
||||
}
|
||||
|
||||
function contentFor(spec: ImportTemplateSpec, format: TemplateFormat) {
|
||||
if (format === 'csv') return toCsv(spec.csvRows);
|
||||
return `${JSON.stringify(spec.jsonExample, null, 2)}\n`;
|
||||
}
|
||||
|
||||
export async function importFieldMappingRoute(ctx: RequestContext) {
|
||||
await requireTenantContentEditor(ctx);
|
||||
const importType = importTypeFromQuery(stringParam(ctx, 'importType'));
|
||||
const spec = SPECS[importType];
|
||||
return {
|
||||
item: {
|
||||
importType,
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
fields: spec.fields,
|
||||
requiredFields: spec.fields.filter(field => field.required).map(field => field.field),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function importTemplateRoute(ctx: RequestContext) {
|
||||
await requireTenantContentEditor(ctx);
|
||||
const importType = importTypeFromQuery(stringParam(ctx, 'importType'));
|
||||
const format = formatFromQuery(stringParam(ctx, 'format'));
|
||||
const spec = SPECS[importType];
|
||||
const content = contentFor(spec, format);
|
||||
const extension = format === 'csv' ? 'csv' : 'json';
|
||||
const mimeType = format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json; charset=utf-8';
|
||||
|
||||
return {
|
||||
item: {
|
||||
importType,
|
||||
format,
|
||||
fileName: `${importType}-import-template.${extension}`,
|
||||
mimeType,
|
||||
contentBase64: Buffer.from(content, 'utf8').toString('base64'),
|
||||
contentPreview: content,
|
||||
fields: spec.fields,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,14 @@ import {
|
||||
previewVideosImportRoute,
|
||||
previewVocabularyImportRoute,
|
||||
} from './imports.js';
|
||||
import {
|
||||
importPostCheckRoute,
|
||||
importPostCheckStatusRoute,
|
||||
} from './import-postcheck.js';
|
||||
import {
|
||||
importFieldMappingRoute,
|
||||
importTemplateRoute,
|
||||
} from './import-templates.js';
|
||||
import {
|
||||
contentEntriesAdminRoute,
|
||||
contentNodesAdminRoute,
|
||||
@@ -94,6 +102,10 @@ export const tenantContentRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/tenant-content/imports/videos', importVideosRoute],
|
||||
['GET', '/api/tenant-content/imports', importJobsRoute],
|
||||
['GET', '/api/tenant-content/imports/issues', importIssuesRoute],
|
||||
['GET', '/api/tenant-content/imports/field-mapping', importFieldMappingRoute],
|
||||
['GET', '/api/tenant-content/imports/templates', importTemplateRoute],
|
||||
['POST', '/api/tenant-content/imports/post-check', importPostCheckRoute],
|
||||
['GET', '/api/tenant-content/imports/post-check', importPostCheckStatusRoute],
|
||||
['GET', '/api/tenant-content/videos', videosAdminRoute],
|
||||
['PUT', '/api/tenant-content/videos', upsertVideoRoute],
|
||||
['POST', '/api/tenant-content/question-videos', bindQuestionVideoRoute],
|
||||
|
||||
Reference in New Issue
Block a user