feat: add question export foundation

This commit is contained in:
Codex
2026-06-29 09:05:42 +08:00
parent 35cff06df7
commit f63f8491f6
11 changed files with 901 additions and 22 deletions

View File

@@ -0,0 +1,594 @@
import type pg from 'pg';
import { createHash } from 'node:crypto';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, transaction } from '../../core/db.js';
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
import { boolValue, jsonObjectValue, nullableString } from './utils.js';
const EXPORT_FORMATS = ['json', 'paper_json', 'print_payload'];
const EXPORT_TYPES = ['questions', 'paper', 'daily_practice'];
const SCOPE_TYPES = ['collection', 'entry', 'content_node'];
const SUB_QUESTION_ANSWER_KEYS = new Set([
'correctOptionIndex',
'correct_option_index',
'correctOptionIndices',
'correct_option_indices',
'answerText',
'answer_text',
'answer',
'correctAnswer',
'correct_answer',
'referenceAnswer',
'reference_answer',
'solution',
]);
const SUB_QUESTION_EXPLANATION_KEYS = new Set(['explanation', 'analysis']);
interface ExportScope {
scopeType: 'collection' | 'entry' | 'content_node';
scopeId: string;
regionId: string | null;
entryId: string | null;
entryName: string | null;
contentNodeId: string | null;
contentNodeName: string | null;
collectionId: string | null;
collectionName: string | null;
collectionType: string | null;
durationMinutes: number | null;
totalScore: number | null;
}
interface ExportQuestionRow {
id: string;
legacyId: string | null;
entryId: string | null;
entryName: string | null;
contentNodeId: string | null;
contentNodeName: string | null;
contentNodePath: string | null;
collectionId: string | null;
collectionName: string | null;
subjectId: string | null;
subjectName: string | null;
categoryId: string | null;
categoryName: string | null;
questionBankId: string | null;
type: string;
typeLabel: string | null;
difficulty: number | null;
tags: unknown[];
mediaUrl: string | null;
hasVideoExplanation: boolean;
sectionKey: string | null;
score: number | null;
order: number;
versionId: string | null;
content: string | null;
options: unknown[];
correctOptionIndex: number | null;
correctOptionIndices: unknown[];
answerText: string | null;
explanation: string | null;
subQuestions: unknown[];
codeLang: string | null;
codeTemplate: string | null;
sourceHash: string | null;
}
function choose(value: unknown, allowed: string[], fallback: string, code: string) {
const candidate = nullableString(value) || fallback;
if (!allowed.includes(candidate)) {
throw new HttpError(400, `${candidate} is not supported`, code);
}
return candidate;
}
function base64Json(value: unknown) {
return Buffer.from(JSON.stringify(value, null, 2), 'utf8').toString('base64');
}
function contentHash(value: unknown) {
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
}
function exportLimit(value: unknown) {
const parsed = Number(value ?? 1000);
if (!Number.isFinite(parsed)) return 1000;
return Math.max(1, Math.min(Math.trunc(parsed), 5000));
}
function jsonArray(value: unknown) {
return Array.isArray(value) ? value : [];
}
function redactSubQuestions(value: unknown, includeAnswers: boolean, includeExplanations: boolean): unknown[] {
if (!Array.isArray(value)) return [];
return value.map(item => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
const output: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(item as Record<string, unknown>)) {
if (!includeAnswers && SUB_QUESTION_ANSWER_KEYS.has(key)) continue;
if (!includeExplanations && SUB_QUESTION_EXPLANATION_KEYS.has(key)) continue;
if (key === 'subQuestions' || key === 'sub_questions') {
output[key] = redactSubQuestions(nested, includeAnswers, includeExplanations);
continue;
}
output[key] = nested;
}
return output;
});
}
function formatQuestion(row: ExportQuestionRow, includeAnswers: boolean, includeExplanations: boolean, includeVideoRefs: boolean) {
const question: Record<string, unknown> = {
id: row.id,
legacyId: row.legacyId,
type: row.type,
typeLabel: row.typeLabel,
content: row.content,
options: jsonArray(row.options),
difficulty: row.difficulty,
tags: jsonArray(row.tags),
mediaUrl: row.mediaUrl,
sectionKey: row.sectionKey,
score: row.score,
order: row.order,
subjectId: row.subjectId,
subjectName: row.subjectName,
categoryId: row.categoryId,
categoryName: row.categoryName,
entryId: row.entryId,
entryName: row.entryName,
contentNodeId: row.contentNodeId,
contentNodeName: row.contentNodeName,
collectionId: row.collectionId,
collectionName: row.collectionName,
versionId: row.versionId,
sourceHash: row.sourceHash,
};
if (includeAnswers) {
question.correctOptionIndex = row.correctOptionIndex;
question.correctOptionIndices = jsonArray(row.correctOptionIndices);
question.answerText = row.answerText;
}
if (includeExplanations) {
question.explanation = row.explanation;
}
const subQuestions = redactSubQuestions(row.subQuestions, includeAnswers, includeExplanations);
if (subQuestions.length) {
question.subQuestions = subQuestions;
}
if (row.codeLang || row.codeTemplate) {
question.codeLang = row.codeLang;
question.codeTemplate = row.codeTemplate;
}
if (includeVideoRefs) {
question.hasVideoExplanation = row.hasVideoExplanation;
}
return question;
}
function groupBySection(questions: ReturnType<typeof formatQuestion>[]) {
const groups = new Map<string, { key: string; title: string; questions: ReturnType<typeof formatQuestion>[]; totalScore: number }>();
for (const question of questions) {
const key = typeof question.sectionKey === 'string' && question.sectionKey ? question.sectionKey : String(question.type || 'default');
const title = typeof question.typeLabel === 'string' && question.typeLabel ? question.typeLabel : key;
const group = groups.get(key) || { key, title, questions: [], totalScore: 0 };
group.questions.push(question);
group.totalScore += typeof question.score === 'number' ? question.score : 0;
groups.set(key, group);
}
return Array.from(groups.values()).map(group => ({
...group,
questionCount: group.questions.length,
}));
}
function buildExportPayload(input: {
auth: TenantContentAuth;
jobId: string;
scope: ExportScope;
exportType: string;
format: string;
includeAnswers: boolean;
includeExplanations: boolean;
includeVideoRefs: boolean;
questions: ReturnType<typeof formatQuestion>[];
options: Record<string, unknown>;
createdAt: string;
}) {
const sections = groupBySection(input.questions);
const title = typeof input.options.title === 'string' && input.options.title.trim()
? input.options.title.trim()
: input.scope.collectionName || input.scope.contentNodeName || input.scope.entryName || '题库导出';
const payload = {
_tikuExport: '3.0',
jobId: input.jobId,
tenantId: input.auth.tenantId,
exportedAt: input.createdAt,
exportType: input.exportType,
format: input.format,
title,
scope: input.scope,
options: {
includeAnswers: input.includeAnswers,
includeExplanations: input.includeExplanations,
includeVideoRefs: input.includeVideoRefs,
...input.options,
},
summary: {
questionCount: input.questions.length,
sectionCount: sections.length,
totalScore: sections.reduce((sum, section) => sum + section.totalScore, 0),
durationMinutes: input.scope.durationMinutes,
},
sections,
questions: input.questions,
};
return {
...payload,
files: [
{
filename: `${title.replace(/[\\/:*?"<>|]+/g, '_') || 'question-export'}.json`,
mimeType: 'application/json',
encoding: 'base64',
contentBase64: base64Json(payload),
},
],
renderHints: {
pdfLayout: input.exportType === 'paper' ? 'paper' : 'practice',
pageSize: 'A4',
answerPlacement: input.includeAnswers ? 'inline_or_appendix' : 'hidden',
frontendRenderer: 'apps/taro admin export renderer',
},
};
}
async function loadScope(client: pg.PoolClient, tenantId: string, scopeType: string, scopeId: string): Promise<ExportScope> {
if (scopeType === 'collection') {
const result = await client.query<{
id: string;
regionId: string | null;
entryId: string | null;
entryName: string | null;
contentNodeId: string | null;
contentNodeName: string | null;
collectionName: string;
collectionType: string;
durationMinutes: number | null;
totalScore: string | null;
}>(
`
select qc.id, qc.region_id as "regionId",
qc.entry_id as "entryId", ce.name as "entryName",
qc.node_id as "contentNodeId", cn.name as "contentNodeName",
qc.name as "collectionName", qc.collection_type as "collectionType",
qc.duration_minutes as "durationMinutes", qc.total_score::text as "totalScore"
from public.question_collections qc
left join public.content_entries ce on ce.id = qc.entry_id and ce.tenant_id = qc.tenant_id
left join public.content_nodes cn on cn.id = qc.node_id and cn.tenant_id = qc.tenant_id
where qc.tenant_id = $1 and qc.id = $2 and qc.status = 'active'
limit 1
`,
[tenantId, scopeId],
);
const row = result.rows[0];
if (!row) throw new HttpError(404, 'Question collection not found', 'QUESTION_COLLECTION_NOT_FOUND');
return {
scopeType: 'collection',
scopeId,
regionId: row.regionId,
entryId: row.entryId,
entryName: row.entryName,
contentNodeId: row.contentNodeId,
contentNodeName: row.contentNodeName,
collectionId: row.id,
collectionName: row.collectionName,
collectionType: row.collectionType,
durationMinutes: row.durationMinutes,
totalScore: row.totalScore === null ? null : Number(row.totalScore),
};
}
if (scopeType === 'entry') {
const result = await client.query<{ id: string; regionId: string | null; name: string }>(
`
select id, region_id as "regionId", name
from public.content_entries
where tenant_id = $1 and id = $2 and entry_type = 'question_practice'
limit 1
`,
[tenantId, scopeId],
);
const row = result.rows[0];
if (!row) throw new HttpError(404, 'Content entry not found', 'CONTENT_ENTRY_NOT_FOUND');
return {
scopeType: 'entry',
scopeId,
regionId: row.regionId,
entryId: row.id,
entryName: row.name,
contentNodeId: null,
contentNodeName: null,
collectionId: null,
collectionName: null,
collectionType: null,
durationMinutes: null,
totalScore: null,
};
}
const result = await client.query<{
id: string;
regionId: string | null;
name: string;
entryId: string;
entryName: string;
}>(
`
select cn.id, cn.region_id as "regionId", cn.name,
cn.entry_id as "entryId", ce.name as "entryName"
from public.content_nodes cn
join public.content_entries ce on ce.id = cn.entry_id and ce.tenant_id = cn.tenant_id
where cn.tenant_id = $1 and cn.id = $2
limit 1
`,
[tenantId, scopeId],
);
const row = result.rows[0];
if (!row) throw new HttpError(404, 'Content node not found', 'CONTENT_NODE_NOT_FOUND');
return {
scopeType: 'content_node',
scopeId,
regionId: row.regionId,
entryId: row.entryId,
entryName: row.entryName,
contentNodeId: row.id,
contentNodeName: row.name,
collectionId: null,
collectionName: null,
collectionType: null,
durationMinutes: null,
totalScore: null,
};
}
async function loadQuestions(client: pg.PoolClient, tenantId: string, scopeType: string, scopeId: string, limit: number) {
const params: unknown[] = [tenantId, scopeId, limit];
let scopeFilter = '';
let orderBy = 'q.created_at asc';
if (scopeType === 'collection') {
scopeFilter = 'q.id in (select question_id from public.question_collection_items where tenant_id = $1 and collection_id = $2)';
orderBy = 'coalesce(ci.sort_order, 0) asc, q.created_at asc';
} else if (scopeType === 'entry') {
scopeFilter = 'q.entry_id = $2';
orderBy = 'coalesce(cn.path::text, q.created_at::text) asc, coalesce(ci.sort_order, 0) asc, q.created_at asc';
} else {
scopeFilter = `
(
q.content_node_id = $2
or q.content_node_id in (
select child.id
from public.content_nodes root
join public.content_nodes child on child.tenant_id = root.tenant_id and child.path <@ root.path
where root.tenant_id = $1 and root.id = $2
)
)
`;
orderBy = 'coalesce(cn.path::text, q.created_at::text) asc, coalesce(ci.sort_order, 0) asc, q.created_at asc';
}
const result = await client.query<ExportQuestionRow>(
`
select q.id, q.legacy_id as "legacyId",
q.entry_id as "entryId", ce.name as "entryName",
q.content_node_id as "contentNodeId", cn.name as "contentNodeName", cn.path::text as "contentNodePath",
coalesce(ci.collection_id, q.primary_collection_id) as "collectionId", qc.name as "collectionName",
q.subject_id as "subjectId", s.name as "subjectName",
q.category_id as "categoryId", c.name as "categoryName",
q.question_bank_id as "questionBankId",
q.type, q.type_label as "typeLabel", q.difficulty, q.tags,
q.media_url as "mediaUrl", q.has_video_explanation as "hasVideoExplanation",
ci.section_key as "sectionKey", ci.score, coalesce(ci.sort_order, 0) as "order",
v.id as "versionId", v.content, v.options,
v.correct_option_index as "correctOptionIndex",
v.correct_option_indices as "correctOptionIndices",
v.answer_text as "answerText", v.explanation, v.sub_questions as "subQuestions",
v.code_lang as "codeLang", v.code_template as "codeTemplate", v.source_hash as "sourceHash"
from public.questions q
left join public.question_collection_items ci
on ci.tenant_id = q.tenant_id
and ci.question_id = q.id
and (
($4::text = 'collection' and ci.collection_id = $2)
or ($4::text <> 'collection' and ci.collection_id = q.primary_collection_id)
)
left join public.question_collections qc on qc.id = coalesce(ci.collection_id, q.primary_collection_id) and qc.tenant_id = q.tenant_id
left join public.content_entries ce on ce.id = q.entry_id and ce.tenant_id = q.tenant_id
left join public.content_nodes cn on cn.id = q.content_node_id and cn.tenant_id = q.tenant_id
left join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
left join public.categories c on c.id = q.category_id and c.tenant_id = q.tenant_id
left join public.question_versions v on v.id = q.current_version_id
where q.tenant_id = $1
and q.status = 'published'
and ${scopeFilter}
order by ${orderBy}
limit $3
`,
[...params, scopeType],
);
return result.rows;
}
export async function createQuestionExportRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const scopeType = choose(body.scopeType, SCOPE_TYPES, 'collection', 'INVALID_EXPORT_SCOPE') as ExportScope['scopeType'];
const scopeId = requiredString(body, 'scopeId');
const format = choose(body.format, EXPORT_FORMATS, 'json', 'INVALID_EXPORT_FORMAT');
const exportType = choose(body.exportType, EXPORT_TYPES, format === 'paper_json' ? 'paper' : 'questions', 'INVALID_EXPORT_TYPE');
const includeAnswers = boolValue(body.includeAnswers, true);
const includeExplanations = boolValue(body.includeExplanations, includeAnswers);
const includeVideoRefs = boolValue(body.includeVideoRefs, false);
const limit = exportLimit(body.limit);
const options = body.options && typeof body.options === 'object' && !Array.isArray(body.options) ? body.options as Record<string, unknown> : {};
const result = await transaction(async client => {
const scope = await loadScope(client, auth.tenantId, scopeType, scopeId);
const rows = await loadQuestions(client, auth.tenantId, scopeType, scopeId, limit);
const questions = rows.map(row => formatQuestion(row, includeAnswers, includeExplanations, includeVideoRefs));
const createdAt = new Date().toISOString();
const prePayload = {
tenantId: auth.tenantId,
scope,
exportType,
format,
includeAnswers,
includeExplanations,
includeVideoRefs,
questionCount: questions.length,
questions,
options,
createdAt,
};
const outputHash = contentHash(prePayload);
const job = await client.query<{
id: string;
createdAt: string;
}>(
`
insert into public.content_export_jobs (
tenant_id, created_by, export_type, format, scope_type, scope_id,
status, include_answers, include_explanations, include_video_refs,
question_count, summary, options, output_metadata, finished_at
)
values (
$1, $2, $3, $4, $5, $6,
'completed', $7, $8, $9,
$10, $11::jsonb, $12::jsonb, $13::jsonb, now()
)
returning id, created_at as "createdAt"
`,
[
auth.tenantId,
auth.userId,
exportType,
format,
scopeType,
scopeId,
includeAnswers,
includeExplanations,
includeVideoRefs,
questions.length,
JSON.stringify({
questionCount: questions.length,
scopeName: scope.collectionName || scope.contentNodeName || scope.entryName,
format,
exportType,
}),
jsonObjectValue(options),
JSON.stringify({
outputHash,
delivery: 'inline_payload',
maxQuestionLimit: limit,
}),
],
);
const payload = buildExportPayload({
auth,
jobId: job.rows[0].id,
scope,
exportType,
format,
includeAnswers,
includeExplanations,
includeVideoRefs,
questions,
options,
createdAt,
});
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, 'content.questions.exported', 'content_export_job', $3, $4::jsonb)
`,
[
auth.tenantId,
auth.userId,
job.rows[0].id,
JSON.stringify({
scopeType,
scopeId,
format,
exportType,
questionCount: questions.length,
includeAnswers,
includeExplanations,
includeVideoRefs,
outputHash,
}),
],
);
return {
job: {
id: job.rows[0].id,
status: 'completed',
createdAt: job.rows[0].createdAt,
questionCount: questions.length,
outputHash,
},
export: payload,
};
});
return result;
}
export async function questionExportJobsRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const scopeType = stringParam(ctx, 'scopeType');
const scopeId = stringParam(ctx, 'scopeId');
const limit = intParam(ctx, 'limit', 50, 200);
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (scopeType) {
if (!SCOPE_TYPES.includes(scopeType)) {
throw new HttpError(400, `${scopeType} is not supported`, 'INVALID_EXPORT_SCOPE');
}
params.push(scopeType);
filters.push(`scope_type = $${params.length}`);
}
if (scopeId) {
params.push(scopeId);
filters.push(`scope_id = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, export_type as "exportType", format, scope_type as "scopeType",
scope_id as "scopeId", status, include_answers as "includeAnswers",
include_explanations as "includeExplanations",
include_video_refs as "includeVideoRefs", question_count as "questionCount",
summary, options, output_metadata as "outputMetadata",
error_message as "errorMessage", finished_at as "finishedAt",
created_by as "createdBy", created_at as "createdAt", updated_at as "updatedAt"
from public.content_export_jobs
where ${filters.join(' and ')}
order by created_at desc
limit $${params.length}
`,
params,
);
return { items };
}

View File

@@ -29,6 +29,10 @@ import {
importFieldMappingRoute,
importTemplateRoute,
} from './import-templates.js';
import {
createQuestionExportRoute,
questionExportJobsRoute,
} from './exports.js';
import {
contentEntriesAdminRoute,
contentNodesAdminRoute,
@@ -108,6 +112,8 @@ export const tenantContentRoutes: RouteDefinition[] = [
['GET', '/api/tenant-content/imports/templates', importTemplateRoute],
['POST', '/api/tenant-content/imports/post-check', importPostCheckRoute],
['GET', '/api/tenant-content/imports/post-check', importPostCheckStatusRoute],
['POST', '/api/tenant-content/exports/questions', createQuestionExportRoute],
['GET', '/api/tenant-content/exports/jobs', questionExportJobsRoute],
['GET', '/api/tenant-content/videos', videosAdminRoute],
['PUT', '/api/tenant-content/videos', upsertVideoRoute],
['POST', '/api/tenant-content/question-videos', bindQuestionVideoRoute],