feat: export daily practice image packages

This commit is contained in:
Codex
2026-06-29 17:40:10 +08:00
parent 60501a0f70
commit a68b4ab9e3
16 changed files with 809 additions and 62 deletions

View File

@@ -7,7 +7,7 @@ import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
import { boolValue, jsonObjectValue, nullableString } from './utils.js';
const INLINE_EXPORT_FORMATS = ['json', 'paper_json', 'print_payload'];
const BINARY_EXPORT_FORMATS = ['pdf', 'docx'];
const BINARY_EXPORT_FORMATS = ['pdf', 'docx', 'daily_practice_zip'];
const EXPORT_FORMATS = [...INLINE_EXPORT_FORMATS, ...BINARY_EXPORT_FORMATS];
const EXPORT_TYPES = ['questions', 'paper', 'daily_practice'];
const SCOPE_TYPES = ['collection', 'entry', 'content_node'];
@@ -184,6 +184,16 @@ function isBinaryExportFormat(format: string) {
return BINARY_EXPORT_FORMATS.includes(format);
}
function assertExportFormatMatchesType(format: string, exportType: string) {
if (format === 'daily_practice_zip' && exportType !== 'daily_practice') {
throw new HttpError(
400,
'daily_practice_zip is only supported for daily_practice exports',
'DAILY_PRACTICE_ZIP_REQUIRES_DAILY_PRACTICE',
);
}
}
function jsonArray(value: unknown) {
return Array.isArray(value) ? value : [];
}
@@ -732,9 +742,10 @@ export async function createQuestionExportRoute(ctx: RequestContext) {
const exportType = choose(
body.exportType,
EXPORT_TYPES,
format === 'paper_json' || isBinaryExportFormat(format) ? 'paper' : 'questions',
format === 'daily_practice_zip' ? 'daily_practice' : format === 'paper_json' || isBinaryExportFormat(format) ? 'paper' : 'questions',
'INVALID_EXPORT_TYPE',
);
assertExportFormatMatchesType(format, exportType);
const includeAnswers = boolValue(body.includeAnswers, true);
const includeExplanations = boolValue(body.includeExplanations, includeAnswers);
const includeVideoRefs = boolValue(body.includeVideoRefs, false);

View File

@@ -16,9 +16,11 @@
"exports:once": "tsx src/index.ts --once --job exports"
},
"dependencies": {
"@resvg/resvg-js": "^2.6.2",
"@supabase/storage-js": "^2.108.2",
"ali-oss": "^6.23.0",
"docx": "^9.7.1",
"jszip": "^3.10.1",
"pdfkit": "^0.19.1",
"pg": "^8.16.3"
},

View File

@@ -0,0 +1,373 @@
import fs from 'node:fs';
import { Resvg } from '@resvg/resvg-js';
import JSZip from 'jszip';
import type { BuiltExportPayload } from '../../../api/src/features/tenant-content/exports.js';
import { config } from '../config.js';
interface RenderDailyPracticePackageResult {
body: Buffer;
mimeType: string;
extension: 'zip';
}
type ExportQuestion = BuiltExportPayload['questions'][number];
const CARD_SIZE = 1080;
const COLLAGE_GAP = 28;
const COLLAGE_PADDING = 42;
const CARD_SLOTS = [0, 1, 2, 3, 5, 6, 7, 8];
const FONT_FAMILY = '"Microsoft YaHei", "Noto Sans CJK SC", "Noto Sans SC", sans-serif';
const THEMES: Record<string, {
name: string;
background: string;
card: string;
border: string;
text: string;
muted: string;
accent: string;
accentSoft: string;
center: string;
centerText: string;
}> = {
default: {
name: 'default',
background: '#f6f8fb',
card: '#ffffff',
border: '#d7dee8',
text: '#0f172a',
muted: '#64748b',
accent: '#0f766e',
accentSoft: '#dff7f1',
center: '#0f172a',
centerText: '#ffffff',
},
cream: {
name: 'cream',
background: '#fbf7ef',
card: '#fffdf8',
border: '#e4d6c4',
text: '#1f2933',
muted: '#786a5c',
accent: '#b45309',
accentSoft: '#f7ead8',
center: '#3f2f24',
centerText: '#fff8ed',
},
ink: {
name: 'ink',
background: '#111827',
card: '#f8fafc',
border: '#334155',
text: '#0f172a',
muted: '#475569',
accent: '#2563eb',
accentSoft: '#dbeafe',
center: '#020617',
centerText: '#e2e8f0',
},
};
function boolOption(payload: BuiltExportPayload, key: string) {
return payload.options && typeof payload.options[key] === 'boolean' ? payload.options[key] === true : false;
}
function stringValue(value: unknown, fallback = '') {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function plainText(value: unknown) {
return String(value ?? '')
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, ' [图片] ')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/\r\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function xmlEscape(value: unknown) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function estimateWidth(char: string) {
if (/[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]/.test(char)) return 1;
if (/[A-Z0-9]/.test(char)) return 0.68;
if (/[a-z]/.test(char)) return 0.56;
if (/\s/.test(char)) return 0.35;
return 0.5;
}
function wrapText(value: unknown, maxUnits: number, maxLines: number) {
const normalized = plainText(value).replace(/\s+/g, ' ');
const lines: string[] = [];
let line = '';
let units = 0;
for (const char of normalized) {
const width = estimateWidth(char);
if (units + width > maxUnits && line) {
lines.push(line.trim());
line = '';
units = 0;
if (lines.length >= maxLines) break;
}
line += char;
units += width;
}
if (line && lines.length < maxLines) lines.push(line.trim());
if (lines.length > 0 && normalized.length > lines.join('').length) {
lines[lines.length - 1] = `${lines[lines.length - 1].replace(/[。;,、,.!?;:]*$/, '')}...`;
}
return lines.filter(Boolean);
}
function textLines(input: {
lines: string[];
x: number;
y: number;
fontSize: number;
fill: string;
lineHeight?: number;
weight?: number;
anchor?: 'start' | 'middle';
}) {
const lineHeight = input.lineHeight || Math.round(input.fontSize * 1.42);
return input.lines.map((line, index) => (
`<text x="${input.x}" y="${input.y + index * lineHeight}" font-family="${xmlEscape(FONT_FAMILY)}" font-size="${input.fontSize}" font-weight="${input.weight || 400}" fill="${input.fill}" text-anchor="${input.anchor || 'start'}">${xmlEscape(line)}</text>`
)).join('\n');
}
function optionText(option: unknown, index: number) {
const prefix = `${String.fromCharCode(65 + index)}. `;
if (option && typeof option === 'object' && !Array.isArray(option)) {
const object = option as Record<string, unknown>;
return prefix + plainText(object.text ?? object.label ?? object.content ?? JSON.stringify(object));
}
return prefix + plainText(option);
}
function answerText(question: ExportQuestion) {
const answers: string[] = [];
if (typeof question.correctOptionIndex === 'number') {
answers.push(String.fromCharCode(65 + question.correctOptionIndex));
}
if (Array.isArray(question.correctOptionIndices) && question.correctOptionIndices.length) {
answers.push(question.correctOptionIndices.map(item => String.fromCharCode(65 + Number(item))).join(', '));
}
if (typeof question.answerText === 'string' && question.answerText.trim()) {
answers.push(plainText(question.answerText));
}
return Array.from(new Set(answers.filter(Boolean))).join('');
}
function subQuestionPreview(question: ExportQuestion) {
const subQuestions = Array.isArray(question.subQuestions) ? question.subQuestions : [];
return subQuestions
.slice(0, 2)
.map((item, index) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return '';
const object = item as Record<string, unknown>;
return `(${index + 1}) ${plainText(object.content ?? object.stem ?? object.title ?? '')}`;
})
.filter(Boolean)
.join(' ');
}
function questionTitle(question: ExportQuestion, fallback: string) {
return stringValue(question.subjectName, '') || stringValue(question.categoryName, '') || fallback;
}
function themeFor(payload: BuiltExportPayload) {
const key = payload.dailyPractice?.theme || stringValue(payload.options?.theme, 'default');
return THEMES[key] || THEMES.default;
}
function questionCardSvg(payload: BuiltExportPayload, question: ExportQuestion, index: number) {
const theme = themeFor(payload);
const includeAnswers = boolOption(payload, 'includeAnswers');
const includeExplanations = boolOption(payload, 'includeExplanations');
const title = questionTitle(question, payload.dailyPractice?.issue || payload.title || '每日一练');
const type = stringValue(question.typeLabel, stringValue(question.type, '题目'));
const stem = plainText(question.content);
const subPreview = subQuestionPreview(question);
const options = Array.isArray(question.options) ? question.options.slice(0, 4).map(optionText) : [];
const answer = includeAnswers ? answerText(question) : '';
const explanation = includeExplanations ? plainText(question.explanation).slice(0, 90) : '';
const contentLines = wrapText(subPreview ? `${stem} ${subPreview}` : stem, 21, options.length ? 8 : 11);
const optionLines = options.flatMap(option => wrapText(option, 27, 1)).slice(0, 4);
const footerLines = [
answer ? `答案:${answer}` : '',
explanation ? `解析:${explanation}` : '',
].filter(Boolean);
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${CARD_SIZE}" height="${CARD_SIZE}" viewBox="0 0 ${CARD_SIZE} ${CARD_SIZE}">
<rect width="${CARD_SIZE}" height="${CARD_SIZE}" rx="46" fill="${theme.card}"/>
<rect x="28" y="28" width="${CARD_SIZE - 56}" height="${CARD_SIZE - 56}" rx="38" fill="none" stroke="${theme.border}" stroke-width="4"/>
<rect x="64" y="64" width="148" height="52" rx="26" fill="${theme.accentSoft}"/>
<text x="138" y="99" font-family="${xmlEscape(FONT_FAMILY)}" font-size="26" font-weight="700" fill="${theme.accent}" text-anchor="middle">${String(index + 1).padStart(2, '0')}</text>
<text x="236" y="101" font-family="${xmlEscape(FONT_FAMILY)}" font-size="30" font-weight="700" fill="${theme.text}">${xmlEscape(type)}</text>
<text x="64" y="172" font-family="${xmlEscape(FONT_FAMILY)}" font-size="24" font-weight="500" fill="${theme.muted}">${xmlEscape(title)}</text>
<line x1="64" y1="210" x2="${CARD_SIZE - 64}" y2="210" stroke="${theme.border}" stroke-width="3"/>
${textLines({ lines: contentLines, x: 72, y: 282, fontSize: 42, fill: theme.text, lineHeight: 64, weight: 650 })}
${optionLines.length ? textLines({ lines: optionLines, x: 80, y: 765, fontSize: 32, fill: theme.muted, lineHeight: 48, weight: 500 }) : ''}
${footerLines.length ? `<rect x="64" y="908" width="${CARD_SIZE - 128}" height="102" rx="24" fill="${theme.accentSoft}"/>` : ''}
${footerLines.length ? textLines({ lines: footerLines.flatMap(line => wrapText(line, 30, 1)), x: 92, y: 950, fontSize: 26, fill: theme.accent, lineHeight: 38, weight: 650 }) : ''}
<text x="64" y="1034" font-family="${xmlEscape(FONT_FAMILY)}" font-size="22" fill="${theme.muted}">${xmlEscape(payload.dailyPractice?.brand?.english || 'GONGXUE EDU')}</text>
<text x="${CARD_SIZE - 64}" y="1034" font-family="${xmlEscape(FONT_FAMILY)}" font-size="22" fill="${theme.muted}" text-anchor="end">${xmlEscape(payload.dailyPractice?.date || payload.exportedAt.slice(0, 10))}</text>
</svg>`;
}
function centerCardSvg(payload: BuiltExportPayload) {
const theme = themeFor(payload);
const brand = payload.dailyPractice?.brand;
const center = payload.dailyPractice?.centerSlot;
const titleLines = wrapText(center?.title || brand?.name || '恭学教育', 9, 2);
const subtitleLines = wrapText(center?.subtitle || brand?.ctaLine || '每日一练 · 精选八题 · 稳步上岸', 15, 3);
const sloganLines = wrapText(brand?.slogan || '专注高职升本', 16, 1);
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${CARD_SIZE}" height="${CARD_SIZE}" viewBox="0 0 ${CARD_SIZE} ${CARD_SIZE}">
<rect width="${CARD_SIZE}" height="${CARD_SIZE}" rx="46" fill="${theme.center}"/>
<circle cx="540" cy="270" r="118" fill="${theme.accent}" opacity="0.16"/>
<circle cx="540" cy="270" r="78" fill="${theme.accentSoft}" opacity="0.92"/>
<text x="540" y="288" font-family="${xmlEscape(FONT_FAMILY)}" font-size="40" font-weight="800" fill="${theme.accent}" text-anchor="middle">每日</text>
${textLines({ lines: titleLines, x: 540, y: 482, fontSize: 58, fill: theme.centerText, lineHeight: 76, weight: 800, anchor: 'middle' })}
${textLines({ lines: sloganLines, x: 540, y: 646, fontSize: 30, fill: '#cbd5e1', lineHeight: 42, weight: 500, anchor: 'middle' })}
${textLines({ lines: subtitleLines, x: 540, y: 746, fontSize: 34, fill: theme.centerText, lineHeight: 50, weight: 700, anchor: 'middle' })}
<line x1="230" y1="906" x2="850" y2="906" stroke="#94a3b8" stroke-width="2" opacity="0.42"/>
<text x="540" y="972" font-family="${xmlEscape(FONT_FAMILY)}" font-size="28" fill="#cbd5e1" text-anchor="middle">${xmlEscape(brand?.english || 'GONGXUE EDU')}</text>
</svg>`;
}
function emptyCardSvg(payload: BuiltExportPayload, slot: number) {
const theme = themeFor(payload);
const brand = payload.dailyPractice?.brand;
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${CARD_SIZE}" height="${CARD_SIZE}" viewBox="0 0 ${CARD_SIZE} ${CARD_SIZE}">
<rect width="${CARD_SIZE}" height="${CARD_SIZE}" rx="46" fill="${theme.card}"/>
<rect x="28" y="28" width="${CARD_SIZE - 56}" height="${CARD_SIZE - 56}" rx="38" fill="none" stroke="${theme.border}" stroke-width="4" stroke-dasharray="18 18"/>
<text x="540" y="478" font-family="${xmlEscape(FONT_FAMILY)}" font-size="44" font-weight="800" fill="${theme.muted}" text-anchor="middle">题位待补</text>
<text x="540" y="548" font-family="${xmlEscape(FONT_FAMILY)}" font-size="30" fill="${theme.muted}" text-anchor="middle">Slot ${slot + 1}</text>
<text x="540" y="646" font-family="${xmlEscape(FONT_FAMILY)}" font-size="30" fill="${theme.accent}" text-anchor="middle">${xmlEscape(brand?.ctaLine || '每日一练 · 精选八题 · 稳步上岸')}</text>
</svg>`;
}
function renderSvgToPng(svg: string) {
const fontPath = [config.exportPdfFontPath, 'C:\\Windows\\Fonts\\msyh.ttc', 'C:\\Windows\\Fonts\\simhei.ttf']
.filter((candidate): candidate is string => Boolean(candidate) && fs.existsSync(candidate));
const renderer = new Resvg(svg, {
fitTo: { mode: 'original' },
font: {
loadSystemFonts: true,
fontFiles: fontPath,
defaultFontFamily: 'Microsoft YaHei',
},
});
return renderer.render().asPng();
}
function dataUri(buffer: Buffer) {
return `data:image/png;base64,${buffer.toString('base64')}`;
}
function collageSvg(payload: BuiltExportPayload, pngCards: Buffer[]) {
const theme = themeFor(payload);
const width = CARD_SIZE * 3 + COLLAGE_GAP * 2 + COLLAGE_PADDING * 2;
const height = width;
const title = payload.dailyPractice?.issue || payload.title || '每日一练';
const images = pngCards.map((buffer, slot) => {
const row = Math.floor(slot / 3);
const col = slot % 3;
const x = COLLAGE_PADDING + col * (CARD_SIZE + COLLAGE_GAP);
const y = COLLAGE_PADDING + row * (CARD_SIZE + COLLAGE_GAP);
return `<image href="${dataUri(buffer)}" x="${x}" y="${y}" width="${CARD_SIZE}" height="${CARD_SIZE}"/>`;
}).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect width="${width}" height="${height}" fill="${theme.background}"/>
${images}
<text x="${width - 64}" y="${height - 28}" font-family="${xmlEscape(FONT_FAMILY)}" font-size="24" fill="${theme.muted}" text-anchor="end">${xmlEscape(title)} · ${xmlEscape(payload.dailyPractice?.brand?.name || '恭学教育')}</text>
</svg>`;
}
function manifest(payload: BuiltExportPayload) {
return {
version: '1.0',
generatedBy: 'tiku-saas-export-worker',
generatedAt: new Date().toISOString(),
exportJobId: payload.jobId,
tenantId: payload.tenantId,
exportType: payload.exportType,
format: payload.format,
title: payload.title,
summary: payload.summary,
dailyPractice: payload.dailyPractice,
files: {
collage: {
png: 'collage.png',
svg: 'collage.svg',
},
cards: Array.from({ length: 9 }).map((_, index) => ({
slot: index,
png: `cards/card-${String(index + 1).padStart(2, '0')}.png`,
svg: `cards/card-${String(index + 1).padStart(2, '0')}.svg`,
})),
},
safety: {
includeAnswers: boolOption(payload, 'includeAnswers'),
includeExplanations: boolOption(payload, 'includeExplanations'),
note: 'Card files are rendered server-side from the audited export payload. Private media URLs are not embedded.',
},
};
}
export async function renderDailyPracticePackage(payload: BuiltExportPayload): Promise<RenderDailyPracticePackageResult> {
if (payload.exportType !== 'daily_practice') {
throw new Error('daily_practice_zip renderer only supports daily_practice exports');
}
const zip = new JSZip();
const cardPngs: Buffer[] = [];
const bySlot = new Map<number, ExportQuestion>();
payload.questions.slice(0, 8).forEach((question, index) => {
bySlot.set(CARD_SLOTS[index], question);
});
for (let slot = 0; slot < 9; slot += 1) {
const question = bySlot.get(slot);
const svg = question ? questionCardSvg(payload, question, CARD_SLOTS.indexOf(slot)) : slot === 4 ? centerCardSvg(payload) : emptyCardSvg(payload, slot);
const png = renderSvgToPng(svg);
cardPngs[slot] = png;
const name = String(slot + 1).padStart(2, '0');
zip.file(`cards/card-${name}.svg`, svg);
zip.file(`cards/card-${name}.png`, png);
}
const collageSource = collageSvg(payload, cardPngs);
zip.file('collage.svg', collageSource);
zip.file('collage.png', renderSvgToPng(collageSource));
zip.file('manifest.json', JSON.stringify(manifest(payload), null, 2));
zip.file('payload.json', JSON.stringify(payload, null, 2));
return {
body: await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 7 },
}),
mimeType: 'application/zip',
extension: 'zip',
};
}

View File

@@ -6,6 +6,7 @@ import { config } from '../config.js';
import { closePool as closeApiExportPool } from '../../../api/src/core/db.js';
import { buildExportPayloadForJob } from '../../../api/src/features/tenant-content/exports.js';
import { normalizeStorageProvider, putStorageObject, storageBucket, validateObjectKey } from '../storage.js';
import { renderDailyPracticePackage } from './daily-practice-package.js';
import { renderExportDocx, renderExportPdf } from './export-renderer.js';
interface ExportJobRow {
@@ -13,7 +14,7 @@ interface ExportJobRow {
tenantId: string;
createdBy: string | null;
exportType: string;
format: 'pdf' | 'docx';
format: 'pdf' | 'docx' | 'daily_practice_zip';
scopeType: string;
scopeId: string;
status: string;
@@ -35,7 +36,7 @@ interface ExportWorkerResult {
interface RenderedExport {
body: Buffer;
mimeType: string;
extension: 'pdf' | 'docx';
extension: 'pdf' | 'docx' | 'zip';
}
const ASSET_VISIBILITIES = new Set(['public', 'tenant', 'members', 'svip', 'private']);
@@ -121,7 +122,7 @@ async function claimExportJobs() {
output_metadata as "outputMetadata", created_at as "createdAt"
from public.content_export_jobs
where status = 'pending'
and format in ('pdf', 'docx')
and format in ('pdf', 'docx', 'daily_practice_zip')
and attempt_count < max_attempts
and (next_attempt_at is null or next_attempt_at <= now())
order by created_at asc
@@ -169,7 +170,9 @@ async function renderJob(job: ExportJobRow): Promise<{ rendered: RenderedExport;
});
const rendered = job.format === 'pdf'
? await renderExportPdf(payload)
: await renderExportDocx(payload);
: job.format === 'docx'
? await renderExportDocx(payload)
: await renderDailyPracticePackage(payload);
return {
rendered,
title: payload.title || '题库导出',
@@ -186,7 +189,7 @@ async function insertExportAsset(
input: {
title: string;
fileName: string;
extension: 'pdf' | 'docx';
extension: 'pdf' | 'docx' | 'zip';
mimeType: string;
bucket: string;
objectKey: string;
@@ -197,7 +200,7 @@ async function insertExportAsset(
) {
const visibility = assetVisibility(job.options || {});
const status = assetStatus(job.options || {});
const assetType = input.extension === 'pdf' ? 'pdf' : 'document';
const assetType = input.extension === 'pdf' ? 'pdf' : input.extension === 'docx' ? 'document' : 'package';
const asset = await client.query<{ id: string }>(
`
insert into public.content_assets (
@@ -475,7 +478,7 @@ export async function processExportBatch(): Promise<ExportWorkerResult> {
for (const job of jobs) {
try {
if (job.format !== 'pdf' && job.format !== 'docx') {
if (job.format !== 'pdf' && job.format !== 'docx' && job.format !== 'daily_practice_zip') {
result.skipped += 1;
continue;
}