/** * NAC知识引擎 — PDF合规报告生成模块 (v16) * * 功能: * 1. 按辖区/资产类型/语言筛选规则 * 2. 生成专业PDF报告(NAC品牌头部、规则表格、多语言内容) * 3. 上传到S3并返回下载URL * * 技术:pdfkit + S3存储 */ import PDFDocument from "pdfkit"; import { getMongoDb, COLLECTIONS } from "./mongodb"; import { storagePut } from "./storage"; import path from "path"; import fs from "fs"; // ─── 类型定义 ────────────────────────────────────────────────────── export interface ReportConfig { jurisdiction?: string; assetType?: string; lang: string; title: string; includeDisabled?: boolean; excludeJurisdictions?: string[]; } // ─── 辖区全称映射 ───────────────────────────────────────────────── const JURISDICTION_NAMES: Record> = { CN: { zh: "中国大陆", en: "Mainland China", ar: "الصين", ja: "中国本土", ko: "중국 본토", fr: "Chine continentale", ru: "Материковый Китай" }, HK: { zh: "中国香港", en: "Hong Kong SAR", ar: "هونغ كونغ", ja: "香港", ko: "홍콩", fr: "Hong Kong", ru: "Гонконг" }, SG: { zh: "新加坡", en: "Singapore", ar: "سنغافورة", ja: "シンガポール", ko: "싱가포르", fr: "Singapour", ru: "Сингапур" }, US: { zh: "美国", en: "United States", ar: "الولايات المتحدة", ja: "アメリカ", ko: "미국", fr: "États-Unis", ru: "США" }, EU: { zh: "欧盟", en: "European Union", ar: "الاتحاد الأوروبي", ja: "欧州連合", ko: "유럽연합", fr: "Union européenne", ru: "Европейский союз" }, AE: { zh: "阿联酋", en: "United Arab Emirates", ar: "الإمارات العربية المتحدة", ja: "アラブ首長国連邦", ko: "아랍에미리트", fr: "Émirats arabes unis", ru: "ОАЭ" }, }; // ─── 语言标签映射 ───────────────────────────────────────────────── const LANG_LABELS: Record = { zh: "中文(简体)", en: "English", ar: "العربية", ja: "日本語", ko: "한국어", fr: "Français", ru: "Русский", }; // ─── 查找可用字体(支持中文) ────────────────────────────────────── function findCJKFont(): string | null { const candidates = [ "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", "/usr/share/fonts/truetype/arphic/uming.ttc", "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", ]; for (const p of candidates) { if (fs.existsSync(p)) return p; } return null; } // ─── 主PDF生成函数 ──────────────────────────────────────────────── export async function generateComplianceReport(config: ReportConfig): Promise<{ url: string; filename: string; ruleCount: number; fileSize: number; }> { const db = await getMongoDb(); if (!db) throw new Error("数据库连接失败"); const lang = config.lang || "zh"; // ── 查询规则数据 ────────────────────────────────────────────── const filter: Record = {}; if (config.jurisdiction && config.jurisdiction !== "ALL") filter.jurisdiction = config.jurisdiction; if (config.assetType && config.assetType !== "ALL") filter.assetType = config.assetType; if (!config.includeDisabled) filter.status = "active"; const rules = await db.collection(COLLECTIONS.COMPLIANCE_RULES) .find(filter) .sort({ jurisdiction: 1, assetType: 1, createdAt: 1 }) .toArray() as any[]; if (rules.length === 0) throw new Error("没有找到符合条件的规则,请调整筛选条件"); // ── 生成PDF ────────────────────────────────────────────────── const cjkFont = findCJKFont(); const doc = new PDFDocument({ size: "A4", margins: { top: 60, bottom: 60, left: 60, right: 60 }, info: { Title: config.title, Author: "NAC NewAssetChain", Subject: "Compliance Rules Report", Creator: "NAC Knowledge Engine v16", }, }); const chunks: Buffer[] = []; doc.on("data", (chunk: Buffer) => chunks.push(chunk)); // 注册CJK字体(如果可用) if (cjkFont) { try { doc.registerFont("CJK", cjkFont); } catch { // 字体注册失败,使用默认字体 } } const useCJK = !!cjkFont; const fontName = useCJK ? "CJK" : "Helvetica"; const fontBold = useCJK ? "CJK" : "Helvetica-Bold"; // ── 封面页 ──────────────────────────────────────────────────── // 顶部品牌色条 doc.rect(0, 0, doc.page.width, 8).fill("#1a56db"); // NAC Logo区域 doc.rect(60, 40, 480, 80).fill("#0f172a"); doc.font(fontBold).fontSize(22).fillColor("#60a5fa") .text("NAC", 80, 58); doc.font(fontName).fontSize(10).fillColor("#94a3b8") .text("NewAssetChain — RWA Native Blockchain", 80, 85); doc.font(fontBold).fontSize(14).fillColor("#ffffff") .text("Knowledge Engine", 300, 65, { align: "right", width: 220 }); // 报告标题 doc.moveDown(3); doc.font(fontBold).fontSize(20).fillColor("#1e293b") .text(config.title, 60, 160, { align: "center", width: 480 }); // 报告元信息 const metaY = 210; doc.font(fontName).fontSize(10).fillColor("#64748b"); const jurisdictionLabel = config.jurisdiction && config.jurisdiction !== "ALL" ? (JURISDICTION_NAMES[config.jurisdiction]?.[lang] || config.jurisdiction) : (lang === "zh" ? "全部辖区" : "All Jurisdictions"); const assetTypeLabel = config.assetType && config.assetType !== "ALL" ? config.assetType : (lang === "zh" ? "全部资产类型" : "All Asset Types"); const metaItems = [ { label: lang === "zh" ? "辖区" : "Jurisdiction", value: jurisdictionLabel }, { label: lang === "zh" ? "资产类型" : "Asset Type", value: assetTypeLabel }, { label: lang === "zh" ? "显示语言" : "Language", value: LANG_LABELS[lang] || lang }, { label: lang === "zh" ? "规则总数" : "Total Rules", value: `${rules.length}` }, { label: lang === "zh" ? "生成时间" : "Generated", value: new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }) }, ]; metaItems.forEach((item, i) => { const x = i % 2 === 0 ? 80 : 320; const y = metaY + Math.floor(i / 2) * 22; doc.font(fontBold).fontSize(9).fillColor("#475569").text(item.label + ":", x, y); doc.font(fontName).fontSize(9).fillColor("#1e293b").text(item.value, x + 60, y); }); // 分隔线 doc.moveTo(60, metaY + 80).lineTo(540, metaY + 80).strokeColor("#e2e8f0").lineWidth(1).stroke(); // ── 目录(按辖区分组) ──────────────────────────────────────── const jurisdictions = Array.from(new Set(rules.map((r: any) => r.jurisdiction as string))).sort(); doc.addPage(); doc.rect(0, 0, doc.page.width, 8).fill("#1a56db"); doc.font(fontBold).fontSize(14).fillColor("#1e293b") .text(lang === "zh" ? "目录" : "Table of Contents", 60, 40); doc.moveTo(60, 60).lineTo(540, 60).strokeColor("#e2e8f0").lineWidth(0.5).stroke(); let tocY = 75; jurisdictions.forEach((j, idx) => { const jRules = rules.filter((r: any) => r.jurisdiction === j); const jName = JURISDICTION_NAMES[j]?.[lang] || j; doc.font(fontBold).fontSize(10).fillColor("#1a56db") .text(`${idx + 1}. ${j} — ${jName}`, 60, tocY); doc.font(fontName).fontSize(9).fillColor("#64748b") .text(`${jRules.length} ${lang === "zh" ? "条规则" : "rules"}`, 480, tocY, { align: "right", width: 60 }); tocY += 20; }); // ── 规则内容页(按辖区分组) ────────────────────────────────── jurisdictions.forEach((jurisdiction) => { const jRules = rules.filter((r: any) => r.jurisdiction === jurisdiction); const jName = JURISDICTION_NAMES[jurisdiction]?.[lang] || jurisdiction; doc.addPage(); doc.rect(0, 0, doc.page.width, 8).fill("#1a56db"); // 辖区标题 doc.rect(60, 30, 480, 36).fill("#0f172a"); doc.font(fontBold).fontSize(14).fillColor("#60a5fa") .text(jurisdiction, 75, 40); doc.font(fontName).fontSize(11).fillColor("#94a3b8") .text(jName, 130, 43); doc.font(fontName).fontSize(9).fillColor("#64748b") .text(`${jRules.length} ${lang === "zh" ? "条规则" : "rules"}`, 480, 43, { align: "right", width: 60 }); let ruleY = 85; jRules.forEach((rule: any, idx: number) => { // 检查是否需要新页 if (ruleY > doc.page.height - 120) { doc.addPage(); doc.rect(0, 0, doc.page.width, 8).fill("#1a56db"); ruleY = 30; } const ruleName = rule.ruleNameI18n?.[lang] || rule.ruleName; const ruleDesc = rule.descriptionI18n?.[lang] || rule.description; // 规则序号背景 doc.rect(60, ruleY, 480, 1).fill("#e2e8f0"); ruleY += 8; // 规则编号 + 名称 doc.font(fontBold).fontSize(10).fillColor("#1a56db") .text(`${idx + 1}.`, 60, ruleY, { width: 20 }); doc.font(fontBold).fontSize(10).fillColor("#1e293b") .text(ruleName, 82, ruleY, { width: 400 }); // 资产类型 + 状态徽章 const badgeText = `${rule.assetType}${rule.required ? (lang === "zh" ? " · 强制" : " · Required") : ""}`; doc.font(fontName).fontSize(8).fillColor("#64748b") .text(badgeText, 82, ruleY + 14, { width: 200 }); ruleY += 28; // 规则描述 const descHeight = Math.ceil(ruleDesc.length / 80) * 12 + 8; if (ruleY + descHeight > doc.page.height - 80) { doc.addPage(); doc.rect(0, 0, doc.page.width, 8).fill("#1a56db"); ruleY = 30; } doc.font(fontName).fontSize(9).fillColor("#374151") .text(ruleDesc, 82, ruleY, { width: 440, lineGap: 2 }); ruleY = (doc as any).y + 12; // 标签 if (rule.tags && rule.tags.length > 0) { doc.font(fontName).fontSize(7.5).fillColor("#94a3b8") .text(`${lang === "zh" ? "标签" : "Tags"}: ${rule.tags.join(", ")}`, 82, ruleY); ruleY = (doc as any).y + 8; } }); }); // ── 页脚(每页) ───────────────────────────────────────────── const pageCount = (doc as any).bufferedPageRange?.()?.count || 1; for (let i = 0; i < pageCount; i++) { doc.switchToPage(i); doc.rect(0, doc.page.height - 30, doc.page.width, 30).fill("#f8fafc"); doc.font(fontName).fontSize(7.5).fillColor("#94a3b8") .text( `NAC NewAssetChain Knowledge Engine · ${config.title} · ${new Date().toLocaleDateString("zh-CN")} · Page ${i + 1}`, 60, doc.page.height - 18, { align: "center", width: 480 } ); } doc.end(); // 等待PDF生成完成 const pdfBuffer = await new Promise((resolve, reject) => { doc.on("end", () => resolve(Buffer.concat(chunks))); doc.on("error", reject); }); // ── 上传到S3 ────────────────────────────────────────────────── const timestamp = Date.now(); const safeTitle = config.title.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, "-").slice(0, 30); const filename = `nac-compliance-report-${safeTitle}-${timestamp}.pdf`; const fileKey = `reports/${filename}`; const { url } = await storagePut(fileKey, pdfBuffer, "application/pdf"); return { url, filename, ruleCount: rules.length, fileSize: pdfBuffer.length, }; }