forked from wangziqi/gongxue-base
feat: export daily practice image packages
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
373
apps/worker/src/jobs/daily-practice-package.ts
Normal file
373
apps/worker/src/jobs/daily-practice-package.ts
Normal 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(/ /g, ' ')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function xmlEscape(value: unknown) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user