287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
/**
|
||
* 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<string, Record<string, string>> = {
|
||
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<string, string> = {
|
||
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<string, unknown> = {};
|
||
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<Buffer>((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,
|
||
};
|
||
}
|