forked from wangziqi/gongxue-base
feat: render question exports as assets
This commit is contained in:
@@ -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 ')}
|
||||
|
||||
Reference in New Issue
Block a user