feat: render question exports as assets

This commit is contained in:
Codex
2026-06-29 17:07:24 +08:00
parent 1bcd887731
commit 506d7015a0
23 changed files with 2038 additions and 85 deletions

View File

@@ -6,7 +6,9 @@ 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 INLINE_EXPORT_FORMATS = ['json', 'paper_json', 'print_payload'];
const BINARY_EXPORT_FORMATS = ['pdf', 'docx'];
const EXPORT_FORMATS = [...INLINE_EXPORT_FORMATS, ...BINARY_EXPORT_FORMATS];
const EXPORT_TYPES = ['questions', 'paper', 'daily_practice'];
const SCOPE_TYPES = ['collection', 'entry', 'content_node'];
const SUB_QUESTION_ANSWER_KEYS = new Set([
@@ -77,6 +79,44 @@ interface ExportQuestionRow {
sourceHash: string | null;
}
export interface BuiltExportPayload {
_tikuExport: string;
jobId: string;
tenantId: string;
exportedAt: string;
exportType: string;
format: string;
title: string;
scope: ExportScope;
options: Record<string, unknown>;
summary: {
questionCount: number;
sectionCount: number;
totalScore: number;
durationMinutes: number | null;
};
sections: Array<{
key: string;
title: string;
questions: ReturnType<typeof formatQuestion>[];
totalScore: number;
questionCount: number;
}>;
questions: ReturnType<typeof formatQuestion>[];
files: Array<{
filename: string;
mimeType: string;
encoding: 'base64';
contentBase64: string;
}>;
renderHints: {
pdfLayout: string;
pageSize: string;
answerPlacement: string;
frontendRenderer: string;
};
}
function choose(value: unknown, allowed: string[], fallback: string, code: string) {
const candidate = nullableString(value) || fallback;
if (!allowed.includes(candidate)) {
@@ -99,6 +139,10 @@ function exportLimit(value: unknown) {
return Math.max(1, Math.min(Math.trunc(parsed), 5000));
}
function isBinaryExportFormat(format: string) {
return BINARY_EXPORT_FORMATS.includes(format);
}
function jsonArray(value: unknown) {
return Array.isArray(value) ? value : [];
}
@@ -199,7 +243,7 @@ function buildExportPayload(input: {
questions: ReturnType<typeof formatQuestion>[];
options: Record<string, unknown>;
createdAt: string;
}) {
}): BuiltExportPayload {
const sections = groupBySection(input.questions);
const title = typeof input.options.title === 'string' && input.options.title.trim()
? input.options.title.trim()
@@ -424,6 +468,138 @@ async function loadQuestions(client: pg.PoolClient, tenantId: string, scopeType:
return result.rows;
}
async function createExportJob(input: {
client: pg.PoolClient;
auth: TenantContentAuth;
scope: ExportScope;
scopeType: ExportScope['scopeType'];
scopeId: string;
exportType: string;
format: string;
includeAnswers: boolean;
includeExplanations: boolean;
includeVideoRefs: boolean;
questionCount: number;
options: Record<string, unknown>;
outputMetadata: Record<string, unknown>;
status: 'pending' | 'completed';
finishedAt?: boolean;
}) {
const job = await input.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,
$7, $8, $9, $10,
$11, $12::jsonb, $13::jsonb, $14::jsonb,
case when $15::boolean then now() else null end
)
returning id, created_at as "createdAt"
`,
[
input.auth.tenantId,
input.auth.userId,
input.exportType,
input.format,
input.scopeType,
input.scopeId,
input.status,
input.includeAnswers,
input.includeExplanations,
input.includeVideoRefs,
input.questionCount,
JSON.stringify({
questionCount: input.questionCount,
scopeName: input.scope.collectionName || input.scope.contentNodeName || input.scope.entryName,
format: input.format,
exportType: input.exportType,
}),
jsonObjectValue(input.options),
JSON.stringify(input.outputMetadata),
input.finishedAt === true,
],
);
return job.rows[0];
}
export async function buildExportPayloadForJob(input: {
client: pg.PoolClient;
tenantId: string;
jobId: string;
createdAt?: string;
}): Promise<BuiltExportPayload> {
const job = await input.client.query<{
id: string;
tenantId: string;
createdBy: string | null;
exportType: string;
format: string;
scopeType: ExportScope['scopeType'];
scopeId: string;
includeAnswers: boolean;
includeExplanations: boolean;
includeVideoRefs: boolean;
options: Record<string, unknown>;
outputMetadata: Record<string, unknown>;
createdAt: string;
}>(
`
select id, tenant_id as "tenantId", created_by as "createdBy",
export_type as "exportType", format,
scope_type as "scopeType", scope_id as "scopeId",
include_answers as "includeAnswers",
include_explanations as "includeExplanations",
include_video_refs as "includeVideoRefs",
options, output_metadata as "outputMetadata",
created_at as "createdAt"
from public.content_export_jobs
where tenant_id = $1 and id = $2
limit 1
`,
[input.tenantId, input.jobId],
);
const row = job.rows[0];
if (!row) throw new HttpError(404, 'Export job not found', 'EXPORT_JOB_NOT_FOUND');
const scope = await loadScope(input.client, row.tenantId, row.scopeType, row.scopeId);
const maxQuestionLimit = Number(row.outputMetadata?.maxQuestionLimit ?? row.options?.limit ?? 1000);
const limit = exportLimit(maxQuestionLimit);
const rows = await loadQuestions(input.client, row.tenantId, row.scopeType, row.scopeId, limit);
const questions = rows.map(question => formatQuestion(
question,
row.includeAnswers,
row.includeExplanations,
row.includeVideoRefs,
));
return buildExportPayload({
auth: {
tenantId: row.tenantId,
userId: row.createdBy || row.tenantId,
role: 'system_worker',
permissions: { 'content:*': true },
templatePermissions: {},
},
jobId: row.id,
scope,
exportType: row.exportType,
format: row.format,
includeAnswers: row.includeAnswers,
includeExplanations: row.includeExplanations,
includeVideoRefs: row.includeVideoRefs,
questions,
options: row.options || {},
createdAt: input.createdAt || row.createdAt || new Date().toISOString(),
});
}
export async function createQuestionExportRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
@@ -457,64 +633,47 @@ export async function createQuestionExportRoute(ctx: RequestContext) {
createdAt,
};
const outputHash = contentHash(prePayload);
const binaryExport = isBinaryExportFormat(format);
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({
const job = await createExportJob({
client,
auth,
jobId: job.rows[0].id,
scope,
scopeType,
scopeId,
exportType,
format,
includeAnswers,
includeExplanations,
includeVideoRefs,
questions,
options,
createdAt,
questionCount: questions.length,
options: { ...options, limit },
outputMetadata: {
outputHash,
delivery: binaryExport ? 'content_asset_pending' : 'inline_payload',
maxQuestionLimit: limit,
renderer: binaryExport ? 'worker:exports' : 'api:inline-json',
},
status: binaryExport ? 'pending' : 'completed',
finishedAt: !binaryExport,
});
const payload = binaryExport
? null
: buildExportPayload({
auth,
jobId: job.id,
scope,
exportType,
format,
includeAnswers,
includeExplanations,
includeVideoRefs,
questions,
options,
createdAt: job.createdAt || createdAt,
});
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
@@ -523,7 +682,7 @@ export async function createQuestionExportRoute(ctx: RequestContext) {
[
auth.tenantId,
auth.userId,
job.rows[0].id,
job.id,
JSON.stringify({
scopeType,
scopeId,
@@ -534,15 +693,16 @@ export async function createQuestionExportRoute(ctx: RequestContext) {
includeExplanations,
includeVideoRefs,
outputHash,
delivery: binaryExport ? 'content_asset_pending' : 'inline_payload',
}),
],
);
return {
job: {
id: job.rows[0].id,
status: 'completed',
createdAt: job.rows[0].createdAt,
id: job.id,
status: binaryExport ? 'pending' : 'completed',
createdAt: job.createdAt,
questionCount: questions.length,
outputHash,
},
@@ -580,7 +740,9 @@ export async function questionExportJobsRoute(ctx: RequestContext) {
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",
asset_id as "assetId", error_message as "errorMessage",
started_at as "startedAt", finished_at as "finishedAt",
attempt_count as "attemptCount", max_attempts as "maxAttempts",
created_by as "createdBy", created_at as "createdAt", updated_at as "updatedAt"
from public.content_export_jobs
where ${filters.join(' and ')}